Jelajahi Sumber

add tinyui for lilygo t-echo card

taco 3 bulan lalu
induk
melakukan
8540c98947

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

@@ -0,0 +1,132 @@
+#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"
+    );
+
+    _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);
+
+    // 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;
+  }
+
+};

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

@@ -0,0 +1,810 @@
+#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(2);
+        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 (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 };

+ 7 - 7
variants/lilygo_techo_card/platformio.ini

@@ -16,19 +16,19 @@ build_flags = ${nrf52_base.build_flags}
   -D HAS_NEOPIXEL=1
   ; -D DISABLE_DIAGNOSTIC_OUTPUT
   -D ENV_INCLUDE_GPS=1
-  -D DISPLAY_CLASS=SSD1306Display
+  -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/SSD1306Display.cpp>
+  +<helpers/ui/U8g2Display.h>
   +<helpers/ui/MomentaryButton.cpp>
   +<../variants/lilygo_techo_card>
 lib_deps =
   ${nrf52_base.lib_deps}
   stevemarple/MicroNMEA @ ^2.0.6
-  adafruit/Adafruit SSD1306 @ ^2.5.13
+  olikraus/U8g2 @ ^2.35.19
   adafruit/Adafruit NeoPixel@^1.10.0
   bakercp/CRC32 @ ^2.0.0
 debug_tool = jlink
@@ -68,7 +68,7 @@ board_upload.maximum_size = 712704
 build_flags =
   ${LilyGo_T-Echo_Card.build_flags}
   -I src/helpers/ui
-  -I examples/companion_radio/ui-new
+  -I examples/companion_radio/ui-tiny
   -D PIN_BUZZER=38
   -D QSPIFLASH=1
   -D MAX_CONTACTS=350
@@ -84,7 +84,7 @@ build_flags =
 build_src_filter = ${LilyGo_T-Echo_Card.build_src_filter}
   +<helpers/nrf52/SerialBLEInterface.cpp>
   +<../examples/companion_radio/*.cpp>
-  +<../examples/companion_radio/ui-new/*.cpp>
+  +<../examples/companion_radio/ui-tiny/*.cpp>
   +<helpers/ui/buzzer.cpp>
 lib_deps =
   ${LilyGo_T-Echo_Card.lib_deps}
@@ -98,7 +98,7 @@ board_upload.maximum_size = 712704
 build_flags =
   ${LilyGo_T-Echo_Card.build_flags}
   -I src/helpers/ui
-  -I examples/companion_radio/ui-new
+  -I examples/companion_radio/ui-tiny
   -D PIN_BUZZER=38
   -D QSPIFLASH=1
   -D MAX_CONTACTS=350
@@ -111,7 +111,7 @@ build_flags =
   -D AUTO_SHUTDOWN_MILLIVOLTS=3300
 build_src_filter = ${LilyGo_T-Echo_Card.build_src_filter}
   +<../examples/companion_radio/*.cpp>
-  +<../examples/companion_radio/ui-new/*.cpp>
+  +<../examples/companion_radio/ui-tiny/*.cpp>
 lib_deps =
   ${LilyGo_T-Echo_Card.lib_deps}
   end2endzone/NonBlockingRTTTL@^1.3.0

+ 1 - 1
variants/lilygo_techo_card/target.h

@@ -10,7 +10,7 @@
 #include <helpers/sensors/EnvironmentSensorManager.h>
 #include <helpers/sensors/LocationProvider.h>
 #ifdef DISPLAY_CLASS
-  #include <helpers/ui/SSD1306Display.h>
+  #include <helpers/ui/U8g2Display.h>
   #include <helpers/ui/MomentaryButton.h>
 #endif