Просмотр исходного кода

Merge pull request #2517 from oltaco/techo-card

Add support for LilyGo T-Echo Card (and tiny-ui!)
ripplebiz 2 месяцев назад
Родитель
Сommit
23f48e025f

+ 137 - 0
examples/companion_radio/ui-tiny/ScrollingStatusBar.h

@@ -0,0 +1,137 @@
+#pragma once
+
+#include <helpers/ui/DisplayDriver.h>
+
+
+#ifndef STATUS_BAR_SCROLL_MS
+  #define STATUS_BAR_SCROLL_MS 80
+#endif
+
+#ifndef STATUS_BAR_SEPARATOR
+  #define STATUS_BAR_SEPARATOR " | "
+#endif
+
+#ifndef STATUS_BAR_UPDATE_MS
+  #define STATUS_BAR_UPDATE_MS 2000  // rebuild status string every 2s
+#endif
+
+class ScrollingStatusBar {
+  char _status[160];
+  int _text_width;
+  int _scroll_x;
+  int _display_width;
+  unsigned long _next_scroll;
+  unsigned long _next_update;
+  bool _needs_redraw;
+
+  // cached state for change detection
+  char _last_name[32];
+  uint16_t _last_batt_mv;
+  bool _last_buzzer_quiet;
+  bool _last_gps_on;
+  bool _last_ble_on;
+
+public:
+  ScrollingStatusBar() : _text_width(0), _scroll_x(0), _display_width(72),
+                          _next_scroll(0), _next_update(0), _needs_redraw(true),
+                          _last_batt_mv(0), _last_buzzer_quiet(false),
+                          _last_gps_on(false), _last_ble_on(false) {
+    _status[0] = 0;
+    _last_name[0] = 0;
+  }
+
+  void begin(int display_width) {
+    _display_width = display_width;
+    _scroll_x = 0;
+    _next_scroll = 0;
+    _next_update = 0;
+  }
+
+  // Call periodically to update the status string content.
+  // Only rebuilds if values have changed or update interval has elapsed.
+  void update(DisplayDriver& display, const char* node_name, uint16_t batt_millivolts,
+              bool buzzer_quiet, bool gps_on, bool ble_on) {
+
+    bool changed = (batt_millivolts != _last_batt_mv)
+                || (buzzer_quiet != _last_buzzer_quiet)
+                || (gps_on != _last_gps_on)
+                || (ble_on != _last_ble_on)
+                || (strcmp(node_name, _last_name) != 0);
+
+    if (!changed) return;
+
+    // cache current values
+    strncpy(_last_name, node_name, sizeof(_last_name) - 1);
+    _last_name[sizeof(_last_name) - 1] = 0;
+    _last_batt_mv = batt_millivolts;
+    _last_buzzer_quiet = buzzer_quiet;
+    _last_gps_on = gps_on;
+    _last_ble_on = ble_on;
+
+    float volts = batt_millivolts / 1000.0f;
+
+    snprintf(_status, sizeof(_status),
+      "%s" STATUS_BAR_SEPARATOR
+      "%.2fV" STATUS_BAR_SEPARATOR
+      "BUZ:%s" STATUS_BAR_SEPARATOR
+      "GPS:%s" STATUS_BAR_SEPARATOR
+      "BLE:%s"
+      " - ",  // trailing gap before the text loops
+      node_name,
+      volts,
+      buzzer_quiet ? "OFF" : "ON",
+      gps_on ? "ON" : "OFF",
+      ble_on ? "ON" : "OFF"
+    );
+
+    display.setTextSize(1);
+    _text_width = display.getTextWidth(_status);
+    _next_update = millis() + STATUS_BAR_UPDATE_MS;
+    _needs_redraw = true;
+  }
+
+  // Returns true if the status bar needs a redraw this frame.
+  bool needsRedraw() {
+    if (_text_width <= _display_width) return _needs_redraw;  // static, no scrolling
+    return millis() >= _next_scroll;
+  }
+
+  // Render the status bar via DisplayDriver.
+  // U8g2 full-buffer mode clips to display bounds automatically,
+  // and the font height stays within STATUS_BAR_HEIGHT, so no
+  // explicit clip window is needed.
+  void render(DisplayDriver& display) {
+    if (_status[0] == 0) return;
+
+    display.setTextSize(1);
+    display.setColor(DisplayDriver::GREEN);
+
+    // if (_needs_redraw) {
+    //   _text_width = display.getTextWidth(_status);
+    // }
+
+    // static text: no scrolling needed
+    if (_text_width <= _display_width) {
+      display.setCursor(0, 0);
+      display.print(_status);
+      _needs_redraw = false;
+      return;
+    }
+
+    int x = _scroll_x;
+    do {
+      display.setCursor(x, 0);
+      display.print(_status);
+      x += _text_width;
+    } while (x < _display_width);
+
+
+    // advance scroll position
+    _scroll_x--;
+    if (_scroll_x <= -_text_width) _scroll_x = 0;
+
+    _next_scroll = millis() + STATUS_BAR_SCROLL_MS;
+    _needs_redraw = false;
+  }
+
+};

+ 819 - 0
examples/companion_radio/ui-tiny/UITask.cpp

@@ -0,0 +1,819 @@
+#include "UITask.h"
+#include <helpers/TxtDataHelpers.h>
+#include "../MyMesh.h"
+#include "target.h"
+#include "u8g2_icons.h"
+
+#ifdef WIFI_SSID
+  #include <WiFi.h>
+#endif
+
+#ifndef AUTO_OFF_MILLIS
+  #define AUTO_OFF_MILLIS     15000   // 15 seconds
+#endif
+#define BOOT_SCREEN_MILLIS   4000   // 4 seconds
+
+#ifdef PIN_STATUS_LED
+#define LED_ON_MILLIS     20
+#define LED_ON_MSG_MILLIS 200
+#define LED_CYCLE_MILLIS  4000
+#endif
+
+#define LONG_PRESS_MILLIS   1200
+
+#ifndef UI_RECENT_LIST_SIZE
+  #define UI_RECENT_LIST_SIZE 4
+#endif
+
+#if UI_HAS_JOYSTICK
+  #define PRESS_LABEL "press Enter"
+#else
+  #define PRESS_LABEL "long press"
+#endif
+
+class SplashScreen : public UIScreen {
+  UITask* _task;
+  unsigned long dismiss_after;
+  unsigned long version_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;
+
+    version_after = millis() + BOOT_SCREEN_MILLIS / 2;
+    dismiss_after = millis() + BOOT_SCREEN_MILLIS;
+  }
+
+  int render(DisplayDriver& display) override {
+    if (millis() < version_after) {
+    // meshcore logo
+    display.setColor(DisplayDriver::BLUE);
+    int logoWidth = 72;
+    display.drawXbm(0, 0, meshcore_logo, 72, 36);
+    } else {
+
+    // meshcore website
+    const char* website = "meshcore.io";
+    display.setColor(DisplayDriver::LIGHT);
+    display.setTextSize(1);
+    uint16_t websiteWidth = display.getTextWidth(website);
+    display.setCursor((display.width() - websiteWidth) / 2, 9);
+    display.print(website);
+
+    // version info
+    display.setColor(DisplayDriver::LIGHT);
+    display.setTextSize(1);
+    display.drawTextCentered(display.width()/2, 18, _version_info);
+
+    display.setTextSize(1);
+    display.drawTextCentered(display.width()/2, 27, 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,
+#if ENV_INCLUDE_GPS == 1
+    GPS,
+#endif
+#if UI_SENSORS_PAGE == 1
+    SENSORS,
+#endif
+    SHUTDOWN,
+    Count    // keep as last
+  };
+
+  UITask* _task;
+  mesh::RTCClock* _rtc;
+  SensorManager* _sensors;
+  NodePrefs* _node_prefs;
+  uint8_t _page;
+  bool _shutdown_init;
+  AdvertPath recent[UI_RECENT_LIST_SIZE];
+
+  CayenneLPP sensors_lpp;
+  int sensors_nb = 0;
+  bool sensors_scroll = false;
+  int sensors_scroll_offset = 0;
+  int next_sensors_refresh = 0;
+
+  void refresh_sensors() {
+    if (millis() > next_sensors_refresh) {
+      sensors_lpp.reset();
+      sensors_nb = 0;
+      sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f);
+      sensors.querySensors(0xFF, sensors_lpp);
+      LPPReader reader (sensors_lpp.getBuffer(), sensors_lpp.getSize());
+      uint8_t channel, type;
+      while(reader.readHeader(channel, type)) {
+        reader.skipData(type);
+        sensors_nb ++;
+      }
+      sensors_scroll = sensors_nb > UI_RECENT_LIST_SIZE;
+#if AUTO_OFF_MILLIS > 0
+      next_sensors_refresh = millis() + 5000; // refresh sensor values every 5 sec
+#else
+      next_sensors_refresh = millis() + 60000; // refresh sensor values every 1 min
+#endif
+    }
+  }
+
+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), sensors_lpp(200) {  }
+
+  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];
+
+    if (_page == HomePage::FIRST) {
+      // // node name
+      // display.setTextSize(1);
+      // display.setColor(DisplayDriver::GREEN);
+      // char filtered_name[sizeof(_node_prefs->node_name)];
+      // display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
+      // display.setCursor(0, 0);
+      // display.print(filtered_name);
+
+
+      display.setColor(DisplayDriver::YELLOW);
+      display.setTextSize(2);
+      sprintf(tmp, "MSG: %d", _task->getMsgCount());
+      display.setCursor(0, 10);
+      display.print(tmp);
+
+      sprintf(tmp, "BATT: %.2fV", _task->getCachedBattMV() / 1000.0f);
+      display.setCursor(0, 19);
+      display.print(tmp);
+
+      #ifdef WIFI_SSID
+        IPAddress ip = WiFi.localIP();
+        snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
+        display.setTextSize(1);
+        display.drawTextCentered(display.width() / 2, 54, tmp);
+      #endif
+      if (_task->hasConnection()) {
+        display.setColor(DisplayDriver::GREEN);
+        display.setTextSize(1);
+        display.drawTextCentered(display.width() / 2, display.height()-8, "< 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, display.height()-8, tmp);
+      }
+    } else if (_page == HomePage::RECENT) {
+      the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
+      display.setColor(DisplayDriver::GREEN);
+      int y = 8;
+      for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) {
+        auto a = &recent[i];
+        if (a->name[0] == 0) continue;  // empty slot
+        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));
+        }
+
+        int timestamp_width = display.getTextWidth(tmp);
+        int max_name_width = display.width() - timestamp_width - 1;
+
+        char filtered_recent_name[sizeof(a->name)];
+        display.translateUTF8ToBlocks(filtered_recent_name, a->name, sizeof(filtered_recent_name));
+        display.drawTextEllipsized(0, y, max_name_width, filtered_recent_name);
+        display.setCursor(display.width() - timestamp_width - 1, y);
+        display.print(tmp);
+      }
+    } else if (_page == HomePage::RADIO) {
+      display.setColor(DisplayDriver::YELLOW);
+      display.setTextSize(1);
+      // frequency and spreading factor
+      display.setCursor(0, 8);
+      sprintf(tmp, "FQ %06.3f", _node_prefs->freq);
+      display.print(tmp);
+      sprintf(tmp, "SF%d", _node_prefs->sf);
+      display.drawTextRightAlign(display.width(), 8, tmp);
+      // bandwidth and coding rate
+      display.setCursor(0, 17);
+      sprintf(tmp, "BW %03.2f", _node_prefs->bw);
+      display.print(tmp);
+      sprintf(tmp, "CR%d", _node_prefs->cr);
+      display.drawTextRightAlign(display.width(), 17, tmp);
+      // tx power and noise floor
+      display.setCursor(0, 26);
+      sprintf(tmp, "NF %ddB", radio_driver.getNoiseFloor());
+      display.print(tmp);
+      sprintf(tmp, "TX%d", _node_prefs->tx_power_dbm);
+      display.drawTextRightAlign(display.width(), 26, tmp);
+
+    } else if (_page == HomePage::BLUETOOTH) {
+      display.setColor(DisplayDriver::GREEN);
+      display.drawXbm((display.width() - 32) / 2, 8,
+          _task->isSerialEnabled() ? bluetooth_on : bluetooth_off,
+          32, 32);
+      display.setTextSize(1);
+      // display.drawTextCentered(display.width() / 2, 40 - 11, "toggle: " PRESS_LABEL);
+    } else if (_page == HomePage::ADVERT) {
+      display.setColor(DisplayDriver::GREEN);
+      display.drawXbm((display.width() - 32) / 2, 8, advert_icon, 32, 32);
+      // display.drawTextCentered(display.width() / 2, 40 - 11, "advert: " PRESS_LABEL);
+#if ENV_INCLUDE_GPS == 1
+    } else if (_page == HomePage::GPS) {
+      LocationProvider* nmea = sensors.getLocationProvider();
+      char buf[50];
+      int y = 8;
+      bool gps_state = _task->getGPSState();
+#ifdef PIN_GPS_SWITCH
+      bool hw_gps_state = digitalRead(PIN_GPS_SWITCH);
+      if (gps_state != hw_gps_state) {
+        strcpy(buf, gps_state ? "gps off(hw)" : "gps off(sw)");
+      } else {
+        strcpy(buf, gps_state ? "gps on" : "gps off");
+      }
+#else
+      strcpy(buf, gps_state ? "gps on" : "gps off");
+#endif
+      display.drawTextLeftAlign(0, y, buf);
+      if (nmea == NULL) {
+        // y = y + 8;
+        display.drawTextLeftAlign(0, y, "Can't access GPS");
+      } else {
+        if (!gps_state || !nmea->isValid()) {
+            strcpy(buf, "no fix");
+        } else {
+            sprintf(buf, "%d sat", nmea->satellitesCount());
+        }
+        display.drawTextRightAlign(display.width()-1, y, buf);
+        y = y + 8;
+        sprintf(buf, "lat %.4f",
+          nmea->getLatitude()/1000000.);
+        display.drawTextLeftAlign(0, y, buf);
+        y = y + 8;
+        sprintf(buf, "lon %.4f",
+          nmea->getLongitude()/1000000.);
+        display.drawTextLeftAlign(0, y, buf);
+        y = y + 8;
+        sprintf(buf, "alt %.1f", nmea->getAltitude()/1000.);
+        display.drawTextLeftAlign(0, y, buf);
+      }
+#endif
+#if UI_SENSORS_PAGE == 1
+    } else if (_page == HomePage::SENSORS) {
+      int y = 8;
+      refresh_sensors();
+      char buf[30];
+      char name[30];
+      LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize());
+
+      for (int i = 0; i < sensors_scroll_offset; i++) {
+        uint8_t channel, type;
+        r.readHeader(channel, type);
+        r.skipData(type);
+      }
+
+      for (int i = 0; i < (sensors_scroll?UI_RECENT_LIST_SIZE:sensors_nb); i++) {
+        uint8_t channel, type;
+        if (!r.readHeader(channel, type)) { // reached end, reset
+          r.reset();
+          r.readHeader(channel, type);
+        }
+
+        display.setCursor(0, y);
+        float v;
+        switch (type) {
+          case LPP_GPS: // GPS
+            float lat, lon, alt;
+            r.readGPS(lat, lon, alt);
+            strcpy(name, "gps"); sprintf(buf, "%.4f %.4f", lat, lon);
+            break;
+          case LPP_VOLTAGE:
+            r.readVoltage(v);
+            strcpy(name, "voltage"); sprintf(buf, "%6.2f", v);
+            break;
+          case LPP_CURRENT:
+            r.readCurrent(v);
+            strcpy(name, "current"); sprintf(buf, "%.3f", v);
+            break;
+          case LPP_TEMPERATURE:
+            r.readTemperature(v);
+            strcpy(name, "temperature"); sprintf(buf, "%.2f", v);
+            break;
+          case LPP_RELATIVE_HUMIDITY:
+            r.readRelativeHumidity(v);
+            strcpy(name, "humidity"); sprintf(buf, "%.2f", v);
+            break;
+          case LPP_BAROMETRIC_PRESSURE:
+            r.readPressure(v);
+            strcpy(name, "pressure"); sprintf(buf, "%.2f", v);
+            break;
+          case LPP_ALTITUDE:
+            r.readAltitude(v);
+            strcpy(name, "altitude"); sprintf(buf, "%.0f", v);
+            break;
+          case LPP_POWER:
+            r.readPower(v);
+            strcpy(name, "power"); sprintf(buf, "%6.2f", v);
+            break;
+          default:
+            r.skipData(type);
+            strcpy(name, "unk"); sprintf(buf, "");
+        }
+        display.setCursor(0, y);
+        display.print(name);
+        display.setCursor(
+          display.width()-display.getTextWidth(buf)-1, y
+        );
+        display.print(buf);
+        y = y + 12;
+      }
+      if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb;
+      else sensors_scroll_offset = 0;
+#endif
+    } else if (_page == HomePage::SHUTDOWN) {
+      display.setColor(DisplayDriver::GREEN);
+      display.setTextSize(1);
+      if (_shutdown_init) {
+        display.drawTextCentered(display.width() / 2, 20, "hibernating...");
+      } else {
+        display.drawXbm((display.width() - 32) / 2, 8, power_icon, 32, 32);
+        // display.drawTextCentered(display.width() / 2, 40 - 11, "hibernate:" PRESS_LABEL);
+      }
+    }
+    return 5000;   // next render after 5000 ms
+  }
+
+  bool handleInput(char c) override {
+    if (c == KEY_LEFT || c == KEY_PREV) {
+      _page = (_page + HomePage::Count - 1) % HomePage::Count;
+      return true;
+    }
+    if (c == KEY_NEXT || c == KEY_RIGHT) {
+      _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) {
+      _task->notify(UIEventType::ack);
+      if (the_mesh.advert()) {
+        _task->showAlert("Advert sent!", 1000);
+      } else {
+        _task->showAlert("Advert failed..", 1000);
+      }
+      return true;
+    }
+#if ENV_INCLUDE_GPS == 1
+    if (c == KEY_ENTER && _page == HomePage::GPS) {
+      _task->toggleGPS();
+      return true;
+    }
+#endif
+#if UI_SENSORS_PAGE == 1
+    if (c == KEY_ENTER && _page == HomePage::SENSORS) {
+      _task->toggleGPS();
+      next_sensors_refresh=0;
+      return true;
+    }
+#endif
+    if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) {
+      _shutdown_init = true;  // need to wait for button to be released
+      return true;
+    }
+    return false;
+  }
+};
+
+
+void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) {
+  _display = display;
+  _sensors = sensors;
+  _auto_off = millis() + AUTO_OFF_MILLIS;
+  _cached_batt_mv = getBattMilliVolts();
+
+#if defined(PIN_USER_BTN)
+  user_btn.begin();
+#endif
+#if defined(PIN_USER_BTN_ANA)
+  analog_btn.begin();
+#endif
+
+  _node_prefs = node_prefs;
+
+  if (_display != NULL) {
+    _display->turnOn();
+  }
+  _statusBar.begin(_display->width());
+
+
+#ifdef PIN_BUZZER
+  buzzer.begin();
+  buzzer.quiet(_node_prefs->buzzer_quiet);
+  buzzer.startup();
+#endif
+
+#ifdef PIN_VIBRATION
+  vibration.begin();
+#endif
+
+  ui_started_at = millis();
+  _alert_expiry = 0;
+
+  splash = new SplashScreen(this);
+  home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
+  setCurrScreen(splash);
+}
+
+void UITask::showAlert(const char* text, int duration_millis) {
+  strcpy(_alert, text);
+  _alert_expiry = millis() + duration_millis;
+}
+
+void UITask::notify(UIEventType t) {
+#if defined(PIN_BUZZER)
+switch(t){
+  case UIEventType::contactMessage:
+    // gemini's pick
+    buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7");
+    break;
+  case UIEventType::channelMessage:
+    buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#");
+    break;
+  case UIEventType::ack:
+    buzzer.play("ack:d=32,o=8,b=120:c");
+    break;
+  case UIEventType::roomMessage:
+  case UIEventType::newContactMessage:
+  case UIEventType::none:
+  default:
+    break;
+}
+#endif
+
+#ifdef PIN_VIBRATION
+  // Trigger vibration for all UI events except none
+  if (t != UIEventType::none) {
+    vibration.trigger();
+  }
+#endif
+}
+
+
+void UITask::msgRead(int msgcount) {
+  _msgcount = msgcount;
+  if (msgcount == 0) {
+    gotoHomeScreen();
+  }
+}
+
+void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
+  _msgcount = msgcount;
+
+  if (_display != NULL) {
+    if (!_display->isOn() && !hasConnection()) {
+      _display->turnOn();
+    }
+    if (_display->isOn()) {
+    _auto_off = millis() + AUTO_OFF_MILLIS;  // extend the auto-off timer
+    _next_refresh = 100;  // trigger refresh
+    }
+  }
+}
+
+void UITask::userLedHandler() {
+#ifdef PIN_STATUS_LED
+  int cur_time = millis();
+  if (cur_time > next_led_change) {
+    if (led_state == 0) {
+      led_state = 1;
+      if (_msgcount > 0) {
+        last_led_increment = LED_ON_MSG_MILLIS;
+      } else {
+        last_led_increment = LED_ON_MILLIS;
+      }
+      next_led_change = cur_time + last_led_increment;
+    } else {
+      led_state = 0;
+      next_led_change = cur_time + LED_CYCLE_MILLIS - last_led_increment;
+    }
+    digitalWrite(PIN_STATUS_LED, led_state == LED_STATE_ON);
+  }
+#endif
+}
+
+void UITask::setCurrScreen(UIScreen* c) {
+  curr = c;
+  _next_refresh = 100;
+}
+
+/*
+  hardware-agnostic pre-shutdown activity should be done here
+*/
+void UITask::shutdown(bool restart){
+
+  #ifdef PIN_BUZZER
+  /* note: we have a choice here -
+     we can do a blocking buzzer.loop() with non-deterministic consequences
+     or we can set a flag and delay the shutdown for a couple of seconds
+     while a non-blocking buzzer.loop() plays out in UITask::loop()
+  */
+  buzzer.shutdown();
+  uint32_t buzzer_timer = millis(); // fail-safe shutdown
+  while (buzzer.isPlaying() && (millis() - 2500) < buzzer_timer)
+    buzzer.loop();
+
+  #endif // PIN_BUZZER
+
+  if (restart) {
+    _board->reboot();
+  } else {
+    _display->turnOff();
+    radio_driver.powerOff();
+    _board->powerOff();
+  }
+}
+
+bool UITask::isButtonPressed() const {
+#ifdef PIN_USER_BTN
+  return user_btn.isPressed();
+#else
+  return false;
+#endif
+}
+
+void UITask::loop() {
+  char c = 0;
+#if UI_HAS_JOYSTICK
+  int ev = user_btn.check();
+  if (ev == BUTTON_EVENT_CLICK) {
+    c = checkDisplayOn(KEY_ENTER);
+  } else if (ev == BUTTON_EVENT_LONG_PRESS) {
+    c = handleLongPress(KEY_ENTER);  // REVISIT: could be mapped to different key code
+  }
+  ev = joystick_left.check();
+  if (ev == BUTTON_EVENT_CLICK) {
+    c = checkDisplayOn(KEY_LEFT);
+  } else if (ev == BUTTON_EVENT_LONG_PRESS) {
+    c = handleLongPress(KEY_LEFT);
+  }
+  ev = joystick_right.check();
+  if (ev == BUTTON_EVENT_CLICK) {
+    c = checkDisplayOn(KEY_RIGHT);
+  } else if (ev == BUTTON_EVENT_LONG_PRESS) {
+    c = handleLongPress(KEY_RIGHT);
+  }
+  ev = back_btn.check();
+  if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
+    c = handleTripleClick(KEY_SELECT);
+  }
+#elif defined(PIN_USER_BTN)
+  int ev = user_btn.check();
+  if (ev == BUTTON_EVENT_CLICK) {
+    c = checkDisplayOn(KEY_NEXT);
+  } else if (ev == BUTTON_EVENT_LONG_PRESS) {
+    c = handleLongPress(KEY_ENTER);
+  } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
+    c = handleDoubleClick(KEY_PREV);
+  } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
+    c = handleTripleClick(KEY_SELECT);
+  }
+#endif
+#if defined(PIN_USER_BTN_ANA)
+  if (abs(millis() - _analogue_pin_read_millis) > 10) {
+    int ev = analog_btn.check();
+    if (ev == BUTTON_EVENT_CLICK) {
+      c = checkDisplayOn(KEY_NEXT);
+    } else if (ev == BUTTON_EVENT_LONG_PRESS) {
+      c = handleLongPress(KEY_ENTER);
+    } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
+      c = handleDoubleClick(KEY_PREV);
+    } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
+      c = handleTripleClick(KEY_SELECT);
+    }
+    _analogue_pin_read_millis = millis();
+  }
+#endif
+#if defined(BACKLIGHT_BTN)
+  if (millis() > next_backlight_btn_check) {
+    bool touch_state = digitalRead(PIN_BUTTON2);
+#if defined(DISP_BACKLIGHT)
+    digitalWrite(DISP_BACKLIGHT, !touch_state);
+#elif defined(EXP_PIN_BACKLIGHT)
+    expander.digitalWrite(EXP_PIN_BACKLIGHT, !touch_state);
+#endif
+    next_backlight_btn_check = millis() + 300;
+  }
+#endif
+#if defined(HAS_TORCH)
+  ev = back_btn.check();
+  if (ev == BUTTON_EVENT_CLICK && c == 0) {
+    c = checkDisplayOn(KEY_PREV);
+  } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
+    board.toggleTorch();
+    c = 0;
+  }
+#endif
+
+  if (c != 0 && curr) {
+    curr->handleInput(c);
+    _auto_off = millis() + AUTO_OFF_MILLIS;   // extend auto-off timer
+    _next_refresh = 100;  // trigger refresh
+  }
+
+  userLedHandler();
+
+#ifdef PIN_BUZZER
+  if (buzzer.isPlaying())  buzzer.loop();
+#endif
+
+  if (curr) curr->poll();
+
+  if (_display != NULL && _display->isOn()) {
+        _statusBar.update(*_display,
+        _node_prefs->node_name,
+        _cached_batt_mv,
+        isBuzzerQuiet(),
+        getGPSState(),
+        isSerialEnabled());
+
+    bool status_dirty = _statusBar.needsRedraw();
+    bool content_dirty = (millis() >= _next_refresh && curr);
+
+    if (status_dirty || content_dirty) {
+      _display->startFrame();
+      _statusBar.render(*_display);
+
+      if (curr) {
+        int delay_millis = curr->render(*_display);
+        if (content_dirty) {
+          _next_refresh = millis() + delay_millis;
+        }
+      }
+
+      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
+      } 
+      
+      _display->endFrame();
+    }
+#if AUTO_OFF_MILLIS > 0
+    if (millis() > _auto_off) {
+      _display->turnOff();
+    }
+#endif
+  }
+
+#ifdef PIN_VIBRATION
+  vibration.loop();
+#endif
+
+#ifdef AUTO_SHUTDOWN_MILLIVOLTS
+  if (millis() > next_batt_chck) {
+    _cached_batt_mv = getBattMilliVolts();
+    if (_cached_batt_mv > 0 && _cached_batt_mv < AUTO_SHUTDOWN_MILLIVOLTS) {
+
+      shutdown();
+
+    }
+    next_batt_chck = millis() + 8000;
+  }
+#else
+  if (_display != NULL && _display->isOn() && millis >= next_batt_chck) {
+    _cached_batt_mv = getBattMilliVolts();
+    next_batt_chck = millis() + 8000;
+  }
+#endif
+}
+
+char UITask::checkDisplayOn(char c) {
+  if (_display != NULL) {
+    if (!_display->isOn()) {
+      _display->turnOn();   // turn display on and consume event
+      c = 0;
+    }
+    _auto_off = millis() + AUTO_OFF_MILLIS;   // extend auto-off timer
+    _next_refresh = 0;  // trigger refresh
+  }
+  return c;
+}
+
+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
+  }
+  return c;
+}
+
+char UITask::handleDoubleClick(char c) {
+  MESH_DEBUG_PRINTLN("UITask: double click triggered");
+  checkDisplayOn(c);
+  return c;
+}
+
+char UITask::handleTripleClick(char c) {
+  MESH_DEBUG_PRINTLN("UITask: triple click triggered");
+  checkDisplayOn(c);
+  toggleBuzzer();
+  c = 0;
+  return c;
+}
+
+bool UITask::getGPSState() {
+  if (_sensors != NULL) {
+    int num = _sensors->getNumSettings();
+    for (int i = 0; i < num; i++) {
+      if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
+        return !strcmp(_sensors->getSettingValue(i), "1");
+      }
+    }
+  }
+  return false;
+}
+
+void UITask::toggleGPS() {
+    if (_sensors != NULL) {
+    // toggle GPS on/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");
+          _node_prefs->gps_enabled = 0;
+          notify(UIEventType::ack);
+        } else {
+          _sensors->setSettingValue("gps", "1");
+          _node_prefs->gps_enabled = 1;
+          notify(UIEventType::ack);
+        }
+        the_mesh.savePrefs();
+        showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
+        _next_refresh = 0;
+        break;
+      }
+    }
+  }
+}
+
+void UITask::toggleBuzzer() {
+    // Toggle buzzer quiet mode
+  #ifdef PIN_BUZZER
+    if (buzzer.isQuiet()) {
+      buzzer.quiet(false);
+      notify(UIEventType::ack);
+    } else {
+      buzzer.quiet(true);
+    }
+    _node_prefs->buzzer_quiet = buzzer.isQuiet();
+    the_mesh.savePrefs();
+    showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800);
+    _next_refresh = 0;  // trigger refresh
+  #endif
+}

+ 109 - 0
examples/companion_radio/ui-tiny/UITask.h

@@ -0,0 +1,109 @@
+#pragma once
+
+#include <MeshCore.h>
+#include <helpers/ui/DisplayDriver.h>
+#include <helpers/ui/UIScreen.h>
+#include <helpers/SensorManager.h>
+#include <helpers/BaseSerialInterface.h>
+#include <Arduino.h>
+#include <helpers/sensors/LPPDataHelpers.h>
+
+#include "ScrollingStatusBar.h"
+
+
+#ifndef LED_STATE_ON
+  #define LED_STATE_ON 1
+#endif
+
+#ifdef PIN_BUZZER
+  #include <helpers/ui/buzzer.h>
+#endif
+#ifdef PIN_VIBRATION
+  #include <helpers/ui/GenericVibration.h>
+#endif
+
+#include "../AbstractUITask.h"
+#include "../NodePrefs.h"
+
+class UITask : public AbstractUITask {
+  DisplayDriver* _display;
+  SensorManager* _sensors;
+  ScrollingStatusBar _statusBar;
+#ifdef PIN_BUZZER
+  genericBuzzer buzzer;
+#endif
+#ifdef PIN_VIBRATION
+  GenericVibration vibration;
+#endif
+  unsigned long _next_refresh, _auto_off;
+  NodePrefs* _node_prefs;
+  char _alert[80];
+  unsigned long _alert_expiry;
+  int _msgcount;
+  unsigned long ui_started_at, next_batt_chck;
+  int next_backlight_btn_check = 0;
+  uint16_t _cached_batt_mv;
+#ifdef PIN_STATUS_LED
+  int led_state = 0;
+  int next_led_change = 0;
+  int last_led_increment = 0;
+#endif
+
+#ifdef PIN_USER_BTN_ANA
+  unsigned long _analogue_pin_read_millis = millis();
+#endif
+
+  UIScreen* splash;
+  UIScreen* home;
+  // UIScreen* msg_preview;
+  UIScreen* curr;
+
+
+  void userLedHandler();
+
+  // Button action handlers
+  char checkDisplayOn(char c);
+  char handleLongPress(char c);
+  char handleDoubleClick(char c);
+  char handleTripleClick(char c);
+
+  void setCurrScreen(UIScreen* c);
+
+public:
+
+  UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
+    next_batt_chck = _next_refresh = 0;
+    _cached_batt_mv = 0;
+    ui_started_at = 0;
+    curr = NULL;
+  }
+  void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs);
+
+  void gotoHomeScreen() { setCurrScreen(home); }
+  void showAlert(const char* text, int duration_millis);
+  int  getMsgCount() const { return _msgcount; }
+  uint16_t getCachedBattMV() const { return _cached_batt_mv; }
+  bool hasDisplay() const { return _display != NULL; }
+  bool isButtonPressed() const;
+
+  bool isBuzzerQuiet() { 
+#ifdef PIN_BUZZER
+    return buzzer.isQuiet();
+#else
+    return true;
+#endif
+  }
+
+  void toggleBuzzer();
+  bool getGPSState();
+  void toggleGPS();
+
+
+  // from AbstractUITask
+  void msgRead(int msgcount) override;
+  void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override;
+  void notify(UIEventType t = UIEventType::none) override;
+  void loop() override;
+
+  void shutdown(bool restart = false);
+};

+ 104 - 0
examples/companion_radio/ui-tiny/u8g2_icons.h

@@ -0,0 +1,104 @@
+#pragma once
+
+#include <stdint.h>
+// icons converted for use with U8g2 which needs a different format of xbm data.
+
+// 'meshcore', 72x36px
+static const uint8_t meshcore_logo [] = {
+   0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+   0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+   0x00, 0x00, 0x00, 0xf0, 0x00, 0x3e, 0xfe, 0x3f, 0xfe, 0x3f, 0x1e, 0x78,
+   0xf8, 0x00, 0x1f, 0xff, 0x3f, 0xff, 0x3f, 0x1e, 0x78, 0xf8, 0x01, 0x1f,
+   0xff, 0x9f, 0xff, 0x1f, 0x0e, 0x78, 0xf8, 0x81, 0x1f, 0x0f, 0x80, 0x07,
+   0x00, 0x0f, 0x38, 0xf8, 0xc1, 0x1f, 0x0f, 0x80, 0x07, 0x00, 0x0f, 0x3c,
+   0xf8, 0xc3, 0x1f, 0xff, 0x87, 0xff, 0x07, 0xff, 0x3f, 0xf8, 0xe3, 0x1f,
+   0xff, 0x87, 0xff, 0x0f, 0xff, 0x3f, 0xfc, 0xf3, 0x8f, 0xff, 0x07, 0xff,
+   0x1f, 0xff, 0x3f, 0xfc, 0xf3, 0x8f, 0x07, 0x00, 0x00, 0x9e, 0x0f, 0x1e,
+   0xbc, 0x7f, 0x8f, 0x07, 0x00, 0x00, 0x9e, 0x07, 0x1e, 0x9c, 0x3f, 0x8f,
+   0x07, 0x00, 0x00, 0x9f, 0x07, 0x1e, 0x9c, 0x3f, 0x8f, 0xff, 0xcf, 0xff,
+   0x8f, 0x07, 0x1e, 0x1e, 0x1f, 0xc7, 0xff, 0xcf, 0xff, 0x87, 0x07, 0x1e,
+   0x1e, 0x0f, 0xc7, 0xff, 0xc7, 0xff, 0x83, 0x03, 0x0e, 0x00, 0x00, 0x00,
+   0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+   0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+   0xc0, 0xff, 0xf0, 0xff, 0xe0, 0xff, 0xc1, 0xff, 0x07, 0xf0, 0xff, 0xf8,
+   0xff, 0xf1, 0xff, 0xc3, 0xff, 0x07, 0xf0, 0xff, 0xfc, 0xff, 0xf1, 0xff,
+   0xc7, 0xff, 0x07, 0x78, 0x00, 0x3c, 0xe0, 0xf1, 0xc0, 0xe7, 0x01, 0x00,
+   0x78, 0x00, 0x1e, 0xe0, 0xf1, 0x80, 0xe7, 0x01, 0x00, 0x78, 0x00, 0x1e,
+   0xe0, 0xf1, 0xc0, 0xe3, 0x01, 0x00, 0x78, 0x00, 0x1e, 0xe0, 0x71, 0xc0,
+   0xe3, 0xff, 0x00, 0x3c, 0x00, 0x1e, 0xe0, 0xf9, 0xff, 0xe3, 0xff, 0x00,
+   0x3c, 0x00, 0x1e, 0xe0, 0xf8, 0xff, 0xf1, 0xff, 0x00, 0x3c, 0x00, 0x0e,
+   0xf0, 0xf8, 0xff, 0xf0, 0x00, 0x00, 0x3c, 0x00, 0x1f, 0xf0, 0x78, 0x7c,
+   0xf0, 0x00, 0x00, 0xfc, 0x3f, 0xff, 0xff, 0x38, 0xf8, 0xf0, 0xff, 0x01,
+   0xfc, 0x3f, 0xfe, 0x7f, 0x3c, 0xf0, 0xf0, 0xff, 0x01, 0xf8, 0x3f, 0xfe,
+   0x3f, 0x3c, 0xf0, 0xf1, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+   0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 
+
+  };
+
+
+// bluetooth on icon, 32x32px, horizontal
+static const uint8_t bluetooth_on[] = {
+   0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+   0x00, 0x0c, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x00, 0x00, 0x7c, 0x00, 0x00,
+   0x00, 0xfc, 0x01, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0xdc, 0x07, 0x00,
+   0x0c, 0x1c, 0x1f, 0x00, 0x3c, 0x1c, 0x3e, 0x00, 0x7c, 0x1c, 0x3e, 0x00,
+   0xf8, 0x1d, 0x1f, 0x0e, 0xe0, 0x9f, 0x0f, 0x1e, 0xc0, 0xff, 0x03, 0x1e,
+   0x00, 0xff, 0x01, 0x3c, 0x00, 0xfe, 0xe0, 0x38, 0x00, 0x7e, 0xe0, 0x38,
+   0xc0, 0xff, 0x41, 0x38, 0xc0, 0xff, 0x03, 0x1e, 0xe0, 0xdf, 0x07, 0x1e,
+   0xf0, 0x1d, 0x1f, 0x0e, 0x7c, 0x1c, 0x3e, 0x00, 0x3c, 0x1c, 0x3e, 0x00,
+   0x1c, 0x1c, 0x1f, 0x00, 0x00, 0x9c, 0x0f, 0x00, 0x00, 0xfc, 0x03, 0x00,
+   0x00, 0xfc, 0x01, 0x00, 0x00, 0x7c, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x00,
+   0x00, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
+
+
+// bluetooth off icon, 32x32px, horizontal
+static const uint8_t bluetooth_off[] = {
+   0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x01, 0x00,
+   0x00, 0xc0, 0x03, 0x00, 0x00, 0xc0, 0x07, 0x00, 0x1c, 0xc0, 0x1f, 0x00,
+   0x3c, 0xc0, 0x3f, 0x00, 0x7c, 0xc0, 0xfd, 0x00, 0xf0, 0xc1, 0xf1, 0x01,
+   0xe0, 0xc3, 0xe1, 0x03, 0xc0, 0x0f, 0xc0, 0x03, 0x00, 0x1f, 0xf0, 0x01,
+   0x00, 0x3e, 0xf0, 0x00, 0x00, 0xf8, 0x70, 0x00, 0x00, 0xf0, 0x01, 0x00,
+   0x00, 0xe0, 0x07, 0x00, 0x00, 0xe0, 0x0f, 0x00, 0x00, 0xf0, 0x1f, 0x00,
+   0x00, 0xfc, 0x7d, 0x00, 0x00, 0xfe, 0xf9, 0x00, 0x00, 0xdf, 0xf1, 0x03,
+   0xc0, 0xc7, 0xc1, 0x07, 0xc0, 0xc3, 0xe1, 0x0f, 0xc0, 0xc1, 0xf1, 0x3f,
+   0x00, 0xc0, 0xfd, 0x3c, 0x00, 0xc0, 0x3f, 0x38, 0x00, 0xc0, 0x1f, 0x00,
+   0x00, 0xc0, 0x07, 0x00, 0x00, 0xc0, 0x03, 0x00, 0x00, 0xc0, 0x01, 0x00,
+   0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
+
+
+// power icon, 32x32px, horizontal
+static const uint8_t power_icon[] = {
+   0x00, 0x80, 0x01, 0x00, 0x00, 0xc0, 0x03, 0x00, 0x00, 0xc0, 0x03, 0x00,
+   0x00, 0xcc, 0x33, 0x00, 0x00, 0xcf, 0xf3, 0x00, 0x80, 0xcf, 0xf3, 0x01,
+   0xc0, 0xcf, 0xf3, 0x03, 0xe0, 0xcf, 0xf3, 0x07, 0xf0, 0xc7, 0xe3, 0x0f,
+   0xf8, 0xc3, 0xc3, 0x1f, 0xf8, 0xc1, 0x83, 0x1f, 0xfc, 0xc0, 0x03, 0x3f,
+   0x7c, 0xc0, 0x03, 0x3e, 0x7c, 0xc0, 0x03, 0x3e, 0x7e, 0x80, 0x01, 0x7e,
+   0x3e, 0x00, 0x00, 0x7c, 0x3e, 0x00, 0x00, 0x7c, 0x3e, 0x00, 0x00, 0x7c,
+   0x3e, 0x00, 0x00, 0x7c, 0x3e, 0x00, 0x00, 0x7c, 0x7c, 0x00, 0x00, 0x3e,
+   0x7c, 0x00, 0x00, 0x3e, 0xfc, 0x00, 0x00, 0x3f, 0xf8, 0x01, 0x80, 0x1f,
+   0xf8, 0x03, 0xc0, 0x1f, 0xf0, 0x07, 0xe0, 0x0f, 0xf0, 0x1f, 0xf8, 0x0f,
+   0xe0, 0xff, 0xff, 0x07, 0xc0, 0xff, 0xff, 0x03, 0x00, 0xff, 0xff, 0x00,
+   0x00, 0xfc, 0x3f, 0x00, 0x00, 0xf0, 0x0f, 0x00 };
+
+
+
+
+// 'advert', 32x32px, horizontal
+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, 0x30, 0x00, 0x00, 0x0c,
+   0x38, 0x00, 0x00, 0x1c, 0x18, 0x00, 0x00, 0x18, 0x0c, 0x00, 0x00, 0x30,
+   0x0c, 0x06, 0x60, 0x30, 0x06, 0x07, 0xe0, 0x60, 0x86, 0x03, 0xc0, 0x61,
+   0x87, 0x81, 0x81, 0xe1, 0xc3, 0xe0, 0x07, 0xc3, 0xc3, 0xf0, 0x0f, 0xc3,
+   0xc3, 0xf0, 0x0f, 0xc3, 0xc3, 0xf0, 0x0f, 0xc3, 0xc3, 0xf0, 0x0f, 0xc3,
+   0xc3, 0xe0, 0x07, 0xc3, 0x83, 0xc1, 0x83, 0xc1, 0x86, 0x01, 0x80, 0x61,
+   0x06, 0x03, 0xc0, 0x60, 0x0e, 0x07, 0xe0, 0x70, 0x0c, 0x02, 0x40, 0x30,
+   0x1c, 0x00, 0x00, 0x38, 0x18, 0x00, 0x00, 0x18, 0x30, 0x00, 0x00, 0x0c,
+   0x20, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+   0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
+
+
+
+// 'muted, 8x8px, horizontal
+static const uint8_t muted_icon[] = {
+  0x20, 0x6a, 0xea, 0xe4, 0xe4, 0xea, 0x6a, 0x20 };

+ 127 - 0
src/helpers/ui/U8g2Display.h

@@ -0,0 +1,127 @@
+#pragma once
+
+#include "DisplayDriver.h"
+#include <U8g2lib.h>
+#include <Wire.h>
+
+#ifndef DISPLAY_ADDRESS
+  #define DISPLAY_ADDRESS   0x3C
+#endif
+
+#ifndef OLED_WIDTH
+  #define OLED_WIDTH        72
+#endif
+
+#ifndef OLED_HEIGHT
+  #define OLED_HEIGHT       40
+#endif
+
+class U8g2Display : public DisplayDriver {
+  // U8g2 constructor for SSD1306/SSD1315 72×40 panel — handles all
+  // GDDRAM column/page offsets, SETMULTIPLEX, SETDISPLAYOFFSET internally
+  U8G2_SSD1306_72X40_ER_F_HW_I2C _u8g2;
+  bool _isOn;
+  uint8_t _drawColor;
+
+  // Font metrics for current font (cached on setTextSize)
+  uint8_t _fontAscent;
+  uint8_t _fontHeight;
+
+  void applyFont(int sz) {
+    if (sz >= 2) {
+      _u8g2.setFont(u8g2_font_6x10_mr); // slightly larger font for better readability. TODO: more font sizes?
+    } else {
+      _u8g2.setFont(u8g2_font_5x7_mr);
+    }
+    _fontAscent = _u8g2.getAscent();
+    _fontHeight = _u8g2.getAscent() - _u8g2.getDescent();
+  }
+
+public:
+  U8g2Display() : DisplayDriver(OLED_WIDTH, OLED_HEIGHT),
+      _u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE),
+      _isOn(false), _drawColor(1), _fontAscent(5), _fontHeight(6) {}
+
+  bool begin() {
+    // Wire must already be initialised by board.begin() before this is called
+    bool ok = _u8g2.begin();
+    if (ok) {
+      _u8g2.setI2CAddress(DISPLAY_ADDRESS * 2);  // U8g2 uses 8-bit address
+      _u8g2.setFontPosTop();     // y coordinate = top of text, not baseline
+      _u8g2.setFontMode(1);      // transparent background
+      applyFont(1);              // default to compact font
+      _isOn = true;
+    }
+    return ok;
+  }
+
+  bool isOn() override { return _isOn; }
+
+  void turnOn() override {
+    _u8g2.setPowerSave(0);
+    _isOn = true;
+  }
+
+  void turnOff() override {
+    _u8g2.setPowerSave(1);
+    _isOn = false;
+  }
+
+  void clear() override {
+    _u8g2.clearBuffer();
+    _u8g2.sendBuffer();
+  }
+
+  void startFrame(Color bkg = DARK) override {
+    _u8g2.clearBuffer();
+    _drawColor = 1;
+    _u8g2.setDrawColor(1);
+    applyFont(1);
+  }
+
+  void setTextSize(int sz) override {
+    applyFont(sz);
+  }
+
+  void setColor(Color c) override {
+    _drawColor = (c != DARK) ? 1 : 0;
+    _u8g2.setDrawColor(_drawColor);
+  }
+
+  void setCursor(int x, int y) override {
+    _cursorX = x;
+    _cursorY = y;
+  }
+
+  void print(const char* str) override {
+    _u8g2.setDrawColor(_drawColor);
+    _u8g2.drawStr(_cursorX, _cursorY, str);
+  }
+
+  void fillRect(int x, int y, int w, int h) override {
+    _u8g2.setDrawColor(_drawColor);
+    _u8g2.drawBox(x, y, w, h);
+  }
+
+  void drawRect(int x, int y, int w, int h) override {
+    _u8g2.setDrawColor(_drawColor);
+    _u8g2.drawFrame(x, y, w, h);
+  }
+
+  void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override {
+    _u8g2.setDrawColor(1);
+    _u8g2.drawXBM(x, y, w, h, bits);
+  }
+
+  uint16_t getTextWidth(const char* str) override {
+    return _u8g2.getStrWidth(str);
+  }
+
+  void endFrame() override {
+    _u8g2.sendBuffer();
+  }
+
+private:
+  int _cursorX = 0;
+  int _cursorY = 0;
+};

+ 97 - 0
variants/lilygo_techo_card/TechoCardBoard.cpp

@@ -0,0 +1,97 @@
+#include <Arduino.h>
+#include <Wire.h>
+
+
+#include "TechoCardBoard.h"
+
+#ifdef LILYGO_TECHO_CARD
+
+Adafruit_NeoPixel Led_A(1, WS2812_DATA_2, NEO_GRB + NEO_KHZ800);
+Adafruit_NeoPixel Led_B(1, WS2812_DATA_3, NEO_GRB + NEO_KHZ800);
+Adafruit_NeoPixel Led_C(1, WS2812_DATA_1, NEO_GRB + NEO_KHZ800);
+
+Adafruit_NeoPixel *Led[] =
+    {
+        &Led_A,
+        &Led_B,
+        &Led_C,
+};
+
+
+void TechoCardBoard::begin() {
+    NRF52BoardDCDC::begin();
+    Wire.begin();
+
+    for (uint8_t i = 0; i < sizeof(Led) / sizeof(Led[0]); i++)
+    {
+        Led[i]->begin();
+        delay(3); // allow the LEDs to initialise, otherwise they can get stuck
+        Led[i]->setPixelColor(0, Led[i]->Color(0, 0, 0));
+        Led[i]->show();
+    }
+    
+    // put IMU20948 to sleep
+    // see https://product.tdk.com/system/files/dam/doc/product/sensor/mortion-inertial/imu/data_sheet/ds-000189-icm-20948-v1.5.pdf
+    Wire.beginTransmission(0x68);
+    Wire.write(0x06);   // PWR_MGMT_1 register
+    Wire.write(0x40);   // set SLEEP bit
+    Wire.endTransmission();
+
+}
+
+uint16_t TechoCardBoard::getBattMilliVolts() {
+  int adcvalue = 0;
+
+  analogReference(AR_INTERNAL_3_0);
+  analogReadResolution(12);
+  
+  digitalWrite(PIN_BAT_CTL, HIGH); // enable vbat vdiv
+  delay(10);
+
+  // ADC range is 0..3000mV and resolution is 12-bit (0..4095)
+  adcvalue = analogRead(PIN_VBAT_READ);
+  digitalWrite(PIN_BAT_CTL, LOW);
+
+  // Convert the raw value to compensated mv, taking the resistor-
+  // divider into account (providing the actual LIPO voltage)
+  return (uint16_t)((float)adcvalue * REAL_VBAT_MV_PER_LSB);
+}
+
+void TechoCardBoard::onBeforeTransmit() {
+    Led_A.setPixelColor(0, 20, 20, 20);   // turn TX LED on
+    Led_A.show();
+}
+
+void TechoCardBoard::onAfterTransmit() {
+    Led_A.setPixelColor(0, 0, 0, 0);   // turn TX LED off
+    Led_A.show();
+}
+
+void TechoCardBoard::toggleTorch() {
+    if (!_torchStatus) {
+        Led_C.setPixelColor(0, 255, 255, 255);
+        Led_C.show();
+        _torchStatus = true;
+    } else {
+        Led_C.setPixelColor(0, 0, 0, 0);
+        Led_C.show();
+        _torchStatus = false;
+        }
+}
+
+void TechoCardBoard::turnOffLeds() {
+    for (uint8_t i = 0; i < sizeof(Led) / sizeof(*Led); i++)
+    {
+        Led[i]->setPixelColor(0, 0, 0, 0);
+        Led[i]->show();
+    }
+}
+
+void TechoCardBoard::powerOff() {
+  nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW);
+  turnOffLeds();
+  digitalWrite(PIN_PWR_EN, LOW);
+  sd_power_system_off();
+}
+
+#endif

+ 36 - 0
variants/lilygo_techo_card/TechoCardBoard.h

@@ -0,0 +1,36 @@
+#pragma once
+
+#include <MeshCore.h>
+#include <Arduino.h>
+#include <helpers/NRF52Board.h>
+#include <Adafruit_Neopixel.h>
+
+// built-ins
+#define VBAT_MV_PER_LSB   (0.73242188F)   // 3.0V ADC range and 12-bit ADC resolution = 3000mV/4096
+
+#define VBAT_DIVIDER      (0.5F)          // Even voltage divider on VBAT
+#define VBAT_DIVIDER_COMP (2.0F)          // Compensation factor for the VBAT divider
+
+#define PIN_VBAT_READ     (2)
+#define REAL_VBAT_MV_PER_LSB (VBAT_DIVIDER_COMP * VBAT_MV_PER_LSB)
+
+class TechoCardBoard : public NRF52BoardDCDC {
+  bool _torchStatus = false;
+public:
+  TechoCardBoard() : NRF52Board("TECHO_OTA") {}
+  void begin();
+  uint16_t getBattMilliVolts() override;
+  void onBeforeTransmit(void) override;
+  void onAfterTransmit(void) override;
+
+
+  const char* getManufacturerName() const override {
+    return "LilyGo T-Echo Card";
+  }
+
+  void powerOff() override;
+
+  void toggleTorch();
+  void turnOffLeds();
+  
+};

+ 119 - 0
variants/lilygo_techo_card/platformio.ini

@@ -0,0 +1,119 @@
+[LilyGo_T-Echo_Card]
+extends = nrf52_base
+board = t-echo
+board_build.ldscript = boards/nrf52840_s140_v6.ld
+build_flags = ${nrf52_base.build_flags}
+  -I variants/lilygo_techo_card
+  -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
+  -D LILYGO_TECHO_CARD
+  -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 HAS_NEOPIXEL=1
+  -D HAS_TORCH=1
+  -D DISABLE_DIAGNOSTIC_OUTPUT
+  -D ENV_INCLUDE_GPS=1
+  -D DISPLAY_CLASS=U8g2Display
+  -D PIN_OLED_RESET=-1
+build_src_filter = ${nrf52_base.build_src_filter}
+  +<helpers/*.cpp>
+  +<TechoCardBoard.cpp>
+  +<helpers/sensors/EnvironmentSensorManager.cpp>
+  +<helpers/ui/U8g2Display.h>
+  +<helpers/ui/MomentaryButton.cpp>
+  +<../variants/lilygo_techo_card>
+lib_deps =
+  ${nrf52_base.lib_deps}
+  stevemarple/MicroNMEA @ ^2.0.6
+  olikraus/U8g2 @ ^2.35.19
+  adafruit/Adafruit NeoPixel@^1.10.0
+  bakercp/CRC32 @ ^2.0.0
+debug_tool = jlink
+upload_protocol = nrfutil
+
+[env:LilyGo_T-Echo_Card_repeater]
+extends = LilyGo_T-Echo_Card
+build_src_filter = ${LilyGo_T-Echo_Card.build_src_filter}
+  +<../examples/simple_repeater>
+build_flags =
+  ${LilyGo_T-Echo_Card.build_flags}
+  -D ADVERT_NAME='"T-Echo Card Repeater"'
+  -D ADVERT_LAT=0.0
+  -D ADVERT_LON=0.0
+  -D ADMIN_PASSWORD='"password"'
+  -D MAX_NEIGHBOURS=50
+  ; -D MESH_PACKET_LOGGING=1
+  ; -D MESH_DEBUG=1
+
+[env:LilyGo_T-Echo_Card_room_server]
+extends = LilyGo_T-Echo_Card
+build_src_filter = ${LilyGo_T-Echo_Card.build_src_filter}
+  +<../examples/simple_room_server>
+build_flags =
+  ${LilyGo_T-Echo_Card.build_flags}
+  -D ADVERT_NAME='"T-Echo Card Room"'
+  -D ADVERT_LAT=0.0
+  -D ADVERT_LON=0.0
+  -D ADMIN_PASSWORD='"password"'
+  ; -D MESH_PACKET_LOGGING=1
+  ; -D MESH_DEBUG=1
+
+[env:LilyGo_T-Echo_Card_companion_radio_ble]
+extends = LilyGo_T-Echo_Card
+board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
+board_upload.maximum_size = 712704
+build_flags =
+  ${LilyGo_T-Echo_Card.build_flags}
+  -I src/helpers/ui
+  -I examples/companion_radio/ui-tiny
+  -D PIN_BUZZER=38
+  -D QSPIFLASH=1
+  -D MAX_CONTACTS=350
+  -D MAX_GROUP_CHANNELS=40
+  -D BLE_PIN_CODE=123456
+  ; -D BLE_DEBUG_LOGGING=1
+  -D OFFLINE_QUEUE_SIZE=256
+  -D UI_RECENT_LIST_SIZE=3
+  -D UI_GPS_PAGE=1
+  ; -D MESH_PACKET_LOGGING=1
+  ; -D MESH_DEBUG=1
+  -D AUTO_SHUTDOWN_MILLIVOLTS=3300
+build_src_filter = ${LilyGo_T-Echo_Card.build_src_filter}
+  +<helpers/nrf52/SerialBLEInterface.cpp>
+  +<../examples/companion_radio/*.cpp>
+  +<../examples/companion_radio/ui-tiny/*.cpp>
+  +<helpers/ui/buzzer.cpp>
+lib_deps =
+  ${LilyGo_T-Echo_Card.lib_deps}
+  end2endzone/NonBlockingRTTTL@^1.3.0
+  densaugeo/base64 @ ~1.4.0
+
+[env:LilyGo_T-Echo_Card_companion_radio_usb]
+extends = LilyGo_T-Echo_Card
+board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
+board_upload.maximum_size = 712704
+build_flags =
+  ${LilyGo_T-Echo_Card.build_flags}
+  -I src/helpers/ui
+  -I examples/companion_radio/ui-tiny
+  -D PIN_BUZZER=38
+  -D QSPIFLASH=1
+  -D MAX_CONTACTS=350
+  -D MAX_GROUP_CHANNELS=40
+  -D OFFLINE_QUEUE_SIZE=256
+  -D UI_RECENT_LIST_SIZE=3
+  -D UI_GPS_PAGE=1
+  ; -D MESH_PACKET_LOGGING=1
+  ; -D MESH_DEBUG=1
+  -D AUTO_SHUTDOWN_MILLIVOLTS=3300
+build_src_filter = ${LilyGo_T-Echo_Card.build_src_filter}
+  +<../examples/companion_radio/*.cpp>
+  +<../examples/companion_radio/ui-tiny/*.cpp>
+lib_deps =
+  ${LilyGo_T-Echo_Card.lib_deps}
+  end2endzone/NonBlockingRTTTL@^1.3.0
+  densaugeo/base64 @ ~1.4.0

+ 38 - 0
variants/lilygo_techo_card/target.cpp

@@ -0,0 +1,38 @@
+#include <Arduino.h>
+#include "target.h"
+#include <helpers/ArduinoHelpers.h>
+#include <helpers/sensors/MicroNMEALocationProvider.h>
+
+TechoCardBoard 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);
+
+VolatileRTCClock fallback_clock;
+AutoDiscoverRTCClock rtc_clock(fallback_clock);
+
+#ifdef ENV_INCLUDE_GPS
+MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
+EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
+#else
+EnvironmentSensorManager sensors = EnvironmentSensorManager();
+#endif
+
+
+#ifdef DISPLAY_CLASS
+  DISPLAY_CLASS display;
+  MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
+  MomentaryButton back_btn(PIN_BUTTON2, 1000, true);
+#endif
+
+bool radio_init() {
+  rtc_clock.begin(Wire);
+  
+  return radio.std_init(&SPI);
+}
+
+mesh::LocalIdentity radio_new_identity() {
+  RadioNoiseListener rng(radio);
+  return mesh::LocalIdentity(&rng);  // create new random identity
+}

+ 29 - 0
variants/lilygo_techo_card/target.h

@@ -0,0 +1,29 @@
+#pragma once
+
+#define RADIOLIB_STATIC_ONLY 1
+#include <RadioLib.h>
+#include <helpers/radiolib/RadioLibWrappers.h>
+#include <TechoCardBoard.h>
+#include <helpers/radiolib/CustomSX1262Wrapper.h>
+#include <helpers/AutoDiscoverRTCClock.h>
+#include <helpers/SensorManager.h>
+#include <helpers/sensors/EnvironmentSensorManager.h>
+#include <helpers/sensors/LocationProvider.h>
+#ifdef DISPLAY_CLASS
+  #include <helpers/ui/U8g2Display.h>
+  #include <helpers/ui/MomentaryButton.h>
+#endif
+
+extern TechoCardBoard board;
+extern WRAPPER_CLASS radio_driver;
+extern AutoDiscoverRTCClock rtc_clock;
+extern EnvironmentSensorManager sensors;
+
+#ifdef DISPLAY_CLASS
+  extern DISPLAY_CLASS display;
+  extern MomentaryButton user_btn;
+  extern MomentaryButton back_btn;
+#endif
+
+bool radio_init();
+mesh::LocalIdentity radio_new_identity();

+ 45 - 0
variants/lilygo_techo_card/variant.cpp

@@ -0,0 +1,45 @@
+#include "variant.h"
+#include "wiring_constants.h"
+#include "wiring_digital.h"
+#include "Adafruit_NeoPixel.h"
+
+const int MISO = PIN_SPI_MISO;
+const int MOSI = PIN_SPI_MOSI;
+const int SCK = PIN_SPI_SCK;
+
+
+
+const uint32_t g_ADigitalPinMap[] = {
+  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, 32, 33, 34, 35, 36, 37, 38, 39,
+  40, 41, 42, 43, 44, 45, 46, 47
+};
+
+void initVariant() {
+  // turn on 3v3 rail
+  pinMode(PIN_PWR_EN, OUTPUT);
+  digitalWrite(PIN_PWR_EN, HIGH);
+
+  // VDIV enable
+  pinMode(PIN_BAT_CTL, OUTPUT);
+
+  // buttons
+  pinMode(PIN_BUTTON1, INPUT_PULLUP);
+  pinMode(PIN_BUTTON2, INPUT_PULLUP);
+
+  // speaker
+  pinMode(SPEAKER_EN, OUTPUT);
+  digitalWrite(SPEAKER_EN, LOW);
+  pinMode(SPEAKER_EN_2, OUTPUT);
+  digitalWrite(SPEAKER_EN_2, LOW);
+
+  // gps
+  pinMode(PIN_GPS_STANDBY, OUTPUT);
+  digitalWrite(PIN_GPS_STANDBY, HIGH);
+  pinMode(PIN_GPS_EN, OUTPUT);
+  digitalWrite(PIN_GPS_EN, HIGH);
+  pinMode(PIN_GPS_RESET, OUTPUT);
+  digitalWrite(PIN_GPS_RESET, HIGH);
+
+}

+ 144 - 0
variants/lilygo_techo_card/variant.h

@@ -0,0 +1,144 @@
+/*
+ * 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)
+
+#define WIRE_INTERFACES_COUNT   (1)
+////////////////////////////////////////////////////////////////////////////////
+// Power
+
+#define PIN_PWR_EN              (30)  // RT9080 LDO enable pin for 3v3 rail
+
+#define PIN_BAT_CTL             (31)  // vdiv enable
+#define PIN_VBAT_READ           (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          PIN_GPS_TX
+#define PIN_SERIAL1_TX          PIN_GPS_RX
+////////////////////////////////////////////////////////////////////////////////
+// I2C pin definition
+
+#define PIN_WIRE_SDA            (36)  // P1.04
+#define PIN_WIRE_SCL            (34)  // P1.02
+#define I2C_NO_RESCAN
+#define DISABLE_DS3231_PROBE    // DS3231 lives at 0x68 but this board has ICM20948 at that address, causing broken clock.
+
+////////////////////////////////////////////////////////////////////////////////
+// SPI pin definition
+
+#define SPI_INTERFACES_COUNT    (1)
+
+#define PIN_SPI_MISO            (17)
+#define PIN_SPI_MOSI            (15)
+#define PIN_SPI_SCK             (13)
+
+////////////////////////////////////////////////////////////////////////////////
+// QSPI FLASH
+
+#define PIN_QSPI_SCK            (4)
+#define PIN_QSPI_CS             (12)
+#define PIN_QSPI_IO0            (6)
+#define PIN_QSPI_IO1            (8)
+#define PIN_QSPI_IO2            (41)  // P1.09
+#define PIN_QSPI_IO3            (26)
+
+#define EXTERNAL_FLASH_DEVICES ZD25WQ32CEIGR
+#define EXTERNAL_FLASH_USE_QSPI
+
+////////////////////////////////////////////////////////////////////////////////
+// Builtin LEDs (only WS2812, no traditional LEDs available)
+
+// WS1812 data lines
+#define WS2812_DATA_1           (39)  // P1.07
+#define WS2812_DATA_2           (44)  // P1.12
+#define WS2812_DATA_3           (28)  // P0.28
+
+#define LED_BLUE                (-1)
+#define LED_BUILTIN             (-1)
+#define LED_PIN                 LED_BUILTIN
+#define LED_STATE_ON            LOW
+
+////////////////////////////////////////////////////////////////////////////////
+// Builtin buttons
+
+#define PIN_BUTTON1             (42)  // P1.10
+#define BUTTON_PIN              PIN_BUTTON1 // BUTTON A
+#define PIN_USER_BTN            BUTTON_PIN
+
+#define PIN_BUTTON2             (24)
+#define BUTTON_PIN2             PIN_BUTTON2 // BUTTON C
+
+////////////////////////////////////////////////////////////////////////////////
+// Lora (Acsip S62F) 
+
+#define  USE_SX1262
+#define  P_LORA_SCLK               PIN_SPI_SCK
+#define  P_LORA_MISO               PIN_SPI_MISO
+#define  P_LORA_MOSI               PIN_SPI_MOSI
+#define  P_LORA_DIO_1              (40)  // P1.08
+#define  P_LORA_RESET              (7)   // P0.07
+#define  P_LORA_BUSY               (14)  // P0.14
+#define  P_LORA_NSS                (11)  // P0.11
+#define  SX126X_RXEN               (33)  // P1.01
+#define  SX126X_TXEN               (27)  // P0.27
+#define  SX126X_DIO3_TCXO_VOLTAGE  (1.8f)
+
+
+////////////////////////////////////////////////////////////////////////////////
+// GPS
+
+// NOTE: these pins are defined differently to how lilygo does them but they
+// seem to work properly for how EnvironmentSensorManager operates.
+// TODO: MAYBE? migrate to board based sensor manager / add GPS_WAKE_UP to ESM
+
+#define PIN_GPS_RX              (21) // GPS_UART_RX in lilygo pin defs
+#define PIN_GPS_TX              (19) // GPS_UART_TX in lilygo pin defs
+#define PIN_GPS_EN              (25) // GPS_WAKE_UP in lilygo pin defs
+#define PIN_GPS_RESET           (47) // GPS_EN      in lilygo pin defs
+#define PIN_GPS_STANDBY         (29) // GPS_RF_EN   in lilygo pin defs
+#define PIN_GPS_PPS             (23) // GPS_1PPS    in lilygo pin defs
+
+// buzzer - enabled in platformio.ini so it can be easily turned off if not wanted.
+// #define PIN_BUZZER              (38) // P1.06
+
+// microphone
+#define MICROPHONE_SCLK         (35) // P1.03
+#define MICROPHONE_DATA         (37) // P1.05
+
+// speaker
+#define SPEAKER_EN              (43) // P1.11
+#define SPEAKER_EN_2            (3)  // P0.03
+#define SPEAKER_BCLK            (16) // P0.16
+#define SPEAKER_DATA            (20) // P0.20
+#define SPEAKER_WS_LRCK         (22) // P0.22
+
+// ICM20948 9dof motion sensor (accelerometer and magnetometer)
+#define ICM20948_SDA PIN_WIRE_SDA    // P1.4
+#define ICM20948_SCL PIN_WIRE_SCL    // P1.2
+#define ICM20948_ADDRESS 0x68