#include "UITask.h" #include #include "../MyMesh.h" #include "../MsgExpand.h" #include "target.h" #ifdef WIFI_SSID #include #endif #ifndef AUTO_OFF_MILLIS #define AUTO_OFF_MILLIS 15000 // 15 seconds #endif #define BOOT_SCREEN_MILLIS 3000 // 3 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 #include "icons.h" class SplashScreen : public UIScreen { UITask* _task; unsigned long dismiss_after; char _version_info[12]; char _plus_ver[12]; public: SplashScreen(UITask* task) : _task(task) { // strip off dash and commit hash: 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; // extract plus version: v1.15-plus.1.4-SHA -> "1.4" _plus_ver[0] = '\0'; const char *plus = strstr(ver, "plus."); if (plus) { plus += 5; // skip "plus." const char *end = strchr(plus, '-'); int plen = end ? end - plus : strlen(plus); if (plen >= (int)sizeof(_plus_ver)) plen = sizeof(_plus_ver) - 1; memcpy(_plus_ver, plus, plen); _plus_ver[plen] = '\0'; } dismiss_after = millis() + BOOT_SCREEN_MILLIS; } int render(DisplayDriver& display) override { // meshcore logo display.setColor(DisplayDriver::LIGHT); int logoWidth = 128; display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); // version info display.setTextSize(2); display.drawTextCentered(display.width()/2, 22, _version_info); display.setTextSize(1); display.drawTextCentered(display.width()/2, 42, FIRMWARE_BUILD_DATE); #ifdef FIRMWARE_PLUS_BUILD display.fillRect(0, 53, display.width(), 10); display.setColor(DisplayDriver::DARK); char plus_label[24]; if (_plus_ver[0]) snprintf(plus_label, sizeof(plus_label), "Plus %s for Wio", _plus_ver); else snprintf(plus_label, sizeof(plus_label), "Plus for Wio"); display.drawTextCentered(display.width()/2, 54, plus_label); display.setColor(DisplayDriver::LIGHT); #endif return 1000; } void poll() override { if (millis() >= dismiss_after) { _task->gotoHomeScreen(); } } }; static const int QUICK_MSGS_MAX = 10; // Bit positions for NodePrefs::home_pages_mask. // Bit=1 means page is shown. 0 in the field means ALL visible (default/unset). static const uint16_t HP_CLOCK = 1 << 0; static const uint16_t HP_RECENT = 1 << 1; static const uint16_t HP_RADIO = 1 << 2; static const uint16_t HP_BLUETOOTH = 1 << 3; static const uint16_t HP_ADVERT = 1 << 4; static const uint16_t HP_GPS = 1 << 5; static const uint16_t HP_SENSORS = 1 << 6; static const uint16_t HP_TOOLS = 1 << 7; static const uint16_t HP_SHUTDOWN = 1 << 8; static const uint16_t HP_ALL = 0x01FF; #include "KeyboardWidget.h" #include "FullscreenMsgView.h" #include "SensorPlaceholders.h" class SettingsScreen : public UIScreen { UITask* _task; enum SettingItem { // Display section SECTION_DISPLAY, BRIGHTNESS, AUTO_OFF, BATT_DISPLAY, CLOCK_SECONDS, // Sound section SECTION_SOUND, BUZZER, BUZZER_VOLUME, DM_MELODY, CH_MELODY, // Home pages section SECTION_HOME_PAGES, HOME_CLOCK, HOME_RECENT, HOME_RADIO, HOME_BT, HOME_ADVERT, #if ENV_INCLUDE_GPS == 1 HOME_GPS, #endif #if UI_SENSORS_PAGE == 1 HOME_SENSORS, #endif HOME_TOOLS, HOME_SHUTDOWN, // Radio section SECTION_RADIO, TX_POWER, // System section SECTION_SYSTEM, TIMEZONE, LOW_BAT, #if ENV_INCLUDE_GPS == 1 // GPS section SECTION_GPS, GPS_INTERVAL, #endif // Contacts section SECTION_CONTACTS, DM_FILTER, ROOM_FILTER, // Messages section SECTION_MESSAGES, MSG_SLOT_0, MSG_SLOT_1, MSG_SLOT_2, MSG_SLOT_3, MSG_SLOT_4, MSG_SLOT_5, MSG_SLOT_6, MSG_SLOT_7, MSG_SLOT_8, MSG_SLOT_9, Count }; static const int VISIBLE_ITEMS = 4; static const int ITEM_H = 11; static const int START_Y = 12; static const int VAL_X = 80; int _selected; int _scroll; bool _dirty; static const uint16_t AUTO_OFF_OPTS[5]; static const char* AUTO_OFF_LABELS[5]; static const int AUTO_OFF_COUNT = 5; #if ENV_INCLUDE_GPS == 1 static const uint32_t GPS_INTERVAL_OPTS[6]; static const char* GPS_INTERVAL_LABELS[6]; static const int GPS_INTERVAL_COUNT = 6; #endif static const uint16_t LOW_BAT_OPTS[7]; static const char* LOW_BAT_LABELS[7]; static const int LOW_BAT_COUNT = 7; static const char* BATT_DISPLAY_LABELS[3]; static const int BATT_DISPLAY_COUNT = 3; int lowBatIndex() { NodePrefs* p = _task->getNodePrefs(); if (!p) return 0; for (int i = 0; i < LOW_BAT_COUNT; i++) if (LOW_BAT_OPTS[i] == p->low_batt_mv) return i; return 0; } void renderBar(DisplayDriver& display, int x, int y, int value, int max_val) { const int box_w = 7, box_h = 7, gap = 2; for (int i = 0; i < max_val; i++) { int bx = x + i * (box_w + gap); display.drawRect(bx, y, box_w, box_h); if (i < value) display.fillRect(bx + 1, y + 1, box_w - 2, box_h - 2); } } int autoOffIndex() { NodePrefs* p = _task->getNodePrefs(); if (!p) return 1; for (int i = 0; i < AUTO_OFF_COUNT; i++) if (AUTO_OFF_OPTS[i] == p->auto_off_secs) return i; return 1; } #if ENV_INCLUDE_GPS == 1 int gpsIntervalIndex() { NodePrefs* p = _task->getNodePrefs(); if (!p) return 0; for (int i = 0; i < GPS_INTERVAL_COUNT; i++) if (GPS_INTERVAL_OPTS[i] == p->gps_interval) return i; return 0; } #endif bool isSection(int item) const { return item == SECTION_DISPLAY || item == SECTION_SOUND || item == SECTION_HOME_PAGES || item == SECTION_RADIO || item == SECTION_SYSTEM || item == SECTION_CONTACTS || item == SECTION_MESSAGES #if ENV_INCLUDE_GPS == 1 || item == SECTION_GPS #endif ; } const char* sectionName(int item) const { if (item == SECTION_DISPLAY) return "Display"; if (item == SECTION_SOUND) return "Sound"; if (item == SECTION_HOME_PAGES) return "Home Pages"; if (item == SECTION_RADIO) return "Radio"; if (item == SECTION_SYSTEM) return "System"; if (item == SECTION_CONTACTS) return "Contacts"; if (item == SECTION_MESSAGES) return "Messages"; #if ENV_INCLUDE_GPS == 1 if (item == SECTION_GPS) return "GPS"; #endif return ""; } bool isHomePage(int item) const { return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO || item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS || item == HOME_SHUTDOWN #if ENV_INCLUDE_GPS == 1 || item == HOME_GPS #endif #if UI_SENSORS_PAGE == 1 || item == HOME_SENSORS #endif ; } uint16_t homePageBit(int item) const { if (item == HOME_CLOCK) return HP_CLOCK; if (item == HOME_RECENT) return HP_RECENT; if (item == HOME_RADIO) return HP_RADIO; if (item == HOME_BT) return HP_BLUETOOTH; if (item == HOME_ADVERT) return HP_ADVERT; if (item == HOME_TOOLS) return HP_TOOLS; if (item == HOME_SHUTDOWN) return HP_SHUTDOWN; #if ENV_INCLUDE_GPS == 1 if (item == HOME_GPS) return HP_GPS; #endif #if UI_SENSORS_PAGE == 1 if (item == HOME_SENSORS) return HP_SENSORS; #endif return 0; } const char* homePageLabel(int item) const { if (item == HOME_CLOCK) return "Clock"; if (item == HOME_RECENT) return "Recent"; if (item == HOME_RADIO) return "Radio"; if (item == HOME_BT) return "Bluetooth"; if (item == HOME_ADVERT) return "Advert"; if (item == HOME_TOOLS) return "Tools"; if (item == HOME_SHUTDOWN) return "Shutdown"; #if ENV_INCLUDE_GPS == 1 if (item == HOME_GPS) return "GPS"; #endif #if UI_SENSORS_PAGE == 1 if (item == HOME_SENSORS) return "Sensors"; #endif return ""; } bool homePageVisible(int item, const NodePrefs* p) const { uint16_t bit = homePageBit(item); if (!bit) return false; uint16_t mask = (p && p->home_pages_mask) ? p->home_pages_mask : HP_ALL; return (mask & bit) != 0; } bool isMsgSlot(int item) const { return item >= MSG_SLOT_0 && item <= MSG_SLOT_9; } int msgSlotIndex(int item) const { return item - MSG_SLOT_0; } int nextSelectable(int from) const { for (int i = from + 1; i < (int)Count; i++) if (!isSection(i)) return i; return from; } int prevSelectable(int from) const { for (int i = from - 1; i >= 0; i--) if (!isSection(i)) return i; return from; } void renderItem(DisplayDriver& display, int item, int y) { NodePrefs* p = _task->getNodePrefs(); if (isSection(item)) { display.setColor(DisplayDriver::LIGHT); display.fillRect(0, y, display.width(), 1); display.setCursor(2, y + 2); display.print(sectionName(item)); return; } bool sel = (item == _selected); if (sel) { display.setColor(DisplayDriver::LIGHT); display.fillRect(0, y - 1, display.width(), ITEM_H); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } display.setCursor(0, y); display.print(sel ? ">" : " "); display.setCursor(8, y); if (item == BRIGHTNESS) { display.print("Bright"); renderBar(display, VAL_X, y, (p ? p->display_brightness : 2) + 1, 5); } else if (item == BUZZER) { display.print("Buzzer"); display.setCursor(VAL_X, y); #ifdef PIN_BUZZER { static const char* labels[] = { "ON", "OFF", "Auto" }; int m = _task->getBuzzerMode(); display.print(labels[m < 3 ? m : 0]); } #else display.print("N/A"); #endif } else if (item == BUZZER_VOLUME) { display.print("BzrVol"); #ifdef PIN_BUZZER renderBar(display, VAL_X, y, _task->getBuzzerVolume() + 1, 5); #else display.setCursor(VAL_X, y); display.print("N/A"); #endif } else if (item == DM_MELODY) { display.print("DM sound"); display.setCursor(VAL_X, y); { static const char* L[] = { "built-in", "M1", "M2" }; uint8_t v = p ? p->notif_melody_dm : 0; display.print(L[v < 3 ? v : 0]); } } else if (item == CH_MELODY) { display.print("Ch sound"); display.setCursor(VAL_X, y); { static const char* L[] = { "built-in", "M1", "M2" }; uint8_t v = p ? p->notif_melody_ch : 0; display.print(L[v < 3 ? v : 0]); } } else if (isHomePage(item)) { display.print(homePageLabel(item)); display.setCursor(VAL_X, y); display.print(homePageVisible(item, p) ? "ON" : "OFF"); } else if (item == TX_POWER) { display.print("TX Pwr"); char buf[8]; sprintf(buf, "%ddBm", p ? p->tx_power_dbm : 0); display.setCursor(VAL_X, y); display.print(buf); } else if (item == AUTO_OFF) { display.print("AutoOff"); display.setCursor(VAL_X, y); display.print(AUTO_OFF_LABELS[autoOffIndex()]); #if ENV_INCLUDE_GPS == 1 } else if (item == GPS_INTERVAL) { display.print("GPS upd"); display.setCursor(VAL_X, y); display.print(GPS_INTERVAL_LABELS[gpsIntervalIndex()]); #endif } else if (item == TIMEZONE) { display.print("TimeZone"); char buf[8]; int8_t tz = p ? p->tz_offset_hours : 0; if (tz >= 0) sprintf(buf, "UTC+%d", (int)tz); else sprintf(buf, "UTC%d", (int)tz); display.setCursor(VAL_X, y); display.print(buf); } else if (item == LOW_BAT) { display.print("LowBat"); display.setCursor(VAL_X, y); display.print(LOW_BAT_LABELS[lowBatIndex()]); } else if (item == BATT_DISPLAY) { display.print("BattDisp"); display.setCursor(VAL_X, y); uint8_t mode = p ? p->batt_display_mode : 0; display.print(BATT_DISPLAY_LABELS[mode < BATT_DISPLAY_COUNT ? mode : 0]); } else if (item == CLOCK_SECONDS) { display.print("Seconds"); display.setCursor(VAL_X, y); display.print((p && p->clock_hide_seconds) ? "OFF" : "ON"); } else if (item == DM_FILTER) { display.print("DM"); display.setCursor(VAL_X, y); display.print((p && p->dm_show_all) ? "all" : "fav"); } else if (item == ROOM_FILTER) { display.print("Rooms"); display.setCursor(VAL_X, y); display.print((p && p->room_fav_only) ? "fav" : "all"); } else if (isMsgSlot(item)) { int slot = msgSlotIndex(item); char label[5]; snprintf(label, sizeof(label), "Q%d:", slot + 1); display.print(label); const char* tmpl = (p && p->custom_msgs[slot][0]) ? p->custom_msgs[slot] : "(empty)"; display.drawTextEllipsized(VAL_X - 20, y, display.width() - VAL_X + 18, tmpl); } } // Keyboard state for editing message slots int _edit_slot; // -1 = not editing, 0..9 = slot being edited KeyboardWidget _kb; public: SettingsScreen(UITask* task) : _task(task), _selected(BRIGHTNESS), _scroll(0), _dirty(false), _edit_slot(-1) {} void markClean() { _dirty = false; } int render(DisplayDriver& display) override { display.setTextSize(1); if (_edit_slot >= 0) { return _kb.render(display); } display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(display.width() / 2, 0, "SETTINGS"); display.fillRect(0, 10, display.width(), 1); for (int i = 0; i < VISIBLE_ITEMS && (_scroll + i) < SettingItem::Count; i++) { renderItem(display, _scroll + i, START_Y + i * ITEM_H); } // scroll indicators if (_scroll > 0) { display.setColor(DisplayDriver::LIGHT); display.setCursor(display.width() - 6, START_Y); display.print("^"); } if (_scroll + VISIBLE_ITEMS < SettingItem::Count) { display.setColor(DisplayDriver::LIGHT); display.setCursor(display.width() - 6, START_Y + (VISIBLE_ITEMS - 1) * ITEM_H); display.print("v"); } return 300; } bool handleInput(char c) override { NodePrefs* p = _task->getNodePrefs(); // Keyboard editing mode for message slots if (_edit_slot >= 0) { auto res = _kb.handleInput(c); if (res == KeyboardWidget::DONE) { if (p) { strncpy(p->custom_msgs[_edit_slot], _kb.buf, sizeof(p->custom_msgs[0]) - 1); p->custom_msgs[_edit_slot][sizeof(p->custom_msgs[0]) - 1] = '\0'; _dirty = true; } _edit_slot = -1; } else if (res == KeyboardWidget::CANCELLED) { _edit_slot = -1; } return true; } if (c == KEY_UP) { int prev = prevSelectable(_selected); if (prev != _selected) { _selected = prev; if (_selected < _scroll) _scroll = _selected; } return true; } if (c == KEY_DOWN) { int next = nextSelectable(_selected); if (next != _selected) { _selected = next; if (_selected >= _scroll + VISIBLE_ITEMS) _scroll = _selected - VISIBLE_ITEMS + 1; } return true; } if (c == KEY_CANCEL) { if (_dirty) the_mesh.savePrefs(); _task->gotoHomeScreen(); return true; } bool right = (c == KEY_RIGHT || c == KEY_NEXT); bool left = (c == KEY_LEFT || c == KEY_PREV); bool enter = (c == KEY_ENTER); if (_selected == BRIGHTNESS) { uint8_t lvl = _task->getBrightnessLevel(); if (right && lvl < 4) { _task->setBrightnessLevel(lvl + 1); _dirty = true; return true; } if (left && lvl > 0) { _task->setBrightnessLevel(lvl - 1); _dirty = true; return true; } return right || left; } if (_selected == BUZZER && (left || right || enter)) { _task->cycleBuzzerMode(); _dirty = true; return true; } if (_selected == BUZZER_VOLUME) { #ifdef PIN_BUZZER uint8_t lvl = _task->getBuzzerVolume(); if (right && lvl < 4) { _task->setBuzzerVolumeLevel(lvl + 1); _dirty = true; return true; } if (left && lvl > 0) { _task->setBuzzerVolumeLevel(lvl - 1); _dirty = true; return true; } #endif return right || left; } if (_selected == DM_MELODY && p && (left || right || enter)) { p->notif_melody_dm = (p->notif_melody_dm + (left ? 2 : 1)) % 3; _dirty = true; return true; } if (_selected == CH_MELODY && p && (left || right || enter)) { p->notif_melody_ch = (p->notif_melody_ch + (left ? 2 : 1)) % 3; _dirty = true; return true; } if (isHomePage(_selected) && p && (left || right || enter)) { p->home_pages_mask ^= homePageBit(_selected); _dirty = true; return true; } if (_selected == TX_POWER && p) { if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; } if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); _dirty = true; return true; } } if (_selected == AUTO_OFF && p) { int idx = autoOffIndex(); if (right) idx = (idx + 1) % AUTO_OFF_COUNT; if (left) idx = (idx + AUTO_OFF_COUNT - 1) % AUTO_OFF_COUNT; if (left || right) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; _dirty = true; return true; } } #if ENV_INCLUDE_GPS == 1 if (_selected == GPS_INTERVAL && p) { int idx = gpsIntervalIndex(); if (right) idx = (idx + 1) % GPS_INTERVAL_COUNT; if (left) idx = (idx + GPS_INTERVAL_COUNT - 1) % GPS_INTERVAL_COUNT; if (left || right) { p->gps_interval = GPS_INTERVAL_OPTS[idx]; _task->applyGPSInterval(); _dirty = true; return true; } } #endif if (_selected == TIMEZONE && p) { if (right && p->tz_offset_hours < 14) { p->tz_offset_hours++; _dirty = true; return true; } if (left && p->tz_offset_hours > -12) { p->tz_offset_hours--; _dirty = true; return true; } } if (_selected == LOW_BAT && p) { int idx = lowBatIndex(); if (right) idx = (idx + 1) % LOW_BAT_COUNT; if (left) idx = (idx + LOW_BAT_COUNT - 1) % LOW_BAT_COUNT; if (left || right) { p->low_batt_mv = LOW_BAT_OPTS[idx]; _dirty = true; return true; } } if (_selected == BATT_DISPLAY && p) { int idx = p->batt_display_mode < BATT_DISPLAY_COUNT ? p->batt_display_mode : 0; if (right) idx = (idx + 1) % BATT_DISPLAY_COUNT; if (left) idx = (idx + BATT_DISPLAY_COUNT - 1) % BATT_DISPLAY_COUNT; if (left || right) { p->batt_display_mode = idx; _dirty = true; return true; } } if (_selected == CLOCK_SECONDS && p && (left || right || enter)) { p->clock_hide_seconds ^= 1; _dirty = true; return true; } if (_selected == DM_FILTER && p && (left || right || enter)) { p->dm_show_all = p->dm_show_all ? 0 : 1; _dirty = true; return true; } if (_selected == ROOM_FILTER && p && (left || right || enter)) { p->room_fav_only = p->room_fav_only ? 0 : 1; _dirty = true; return true; } if (isMsgSlot(_selected) && enter) { int slot = msgSlotIndex(_selected); _edit_slot = slot; _kb.begin(p ? p->custom_msgs[slot] : ""); kbAddSensorPlaceholders(_kb, &sensors); return true; } return false; } }; const uint16_t SettingsScreen::AUTO_OFF_OPTS[5] = { 5, 15, 30, 60, 0 }; const char* SettingsScreen::AUTO_OFF_LABELS[5] = { "5s", "15s", "30s", "60s", "never" }; #if ENV_INCLUDE_GPS == 1 const uint32_t SettingsScreen::GPS_INTERVAL_OPTS[6] = { 0, 30, 60, 300, 900, 1800 }; const char* SettingsScreen::GPS_INTERVAL_LABELS[6] = { "off", "30s", "1min", "5min", "15min", "30min" }; #endif const uint16_t SettingsScreen::LOW_BAT_OPTS[7] = { 0, 3000, 3100, 3200, 3300, 3400, 3500 }; const char* SettingsScreen::LOW_BAT_LABELS[7] = { "off", "3.0V", "3.1V", "3.2V", "3.3V", "3.4V", "3.5V" }; const char* SettingsScreen::BATT_DISPLAY_LABELS[3] = { "icon", "%", "V" }; class QuickMsgScreen : public UIScreen { UITask* _task; enum Phase { MODE_SELECT, CONTACT_PICK, DM_HIST, MSG_PICK, CHANNEL_PICK, CHANNEL_HIST, KEYBOARD }; Phase _phase; // MODE_SELECT int _mode_sel; // 0=Direct, 1=Channel, 2=Room Servers // CONTACT_PICK int _contact_sel, _contact_scroll; int _num_contacts; uint16_t _sorted[MAX_CONTACTS]; ContactInfo _sel_contact; bool _room_mode; // true = picking a room server, false = picking a DM contact // CHANNEL_PICK int _channel_sel, _channel_scroll; int _num_channels; uint8_t _channel_indices[MAX_GROUP_CHANNELS]; uint8_t _ch_unread[MAX_GROUP_CHANNELS]; int _sel_channel_idx; bool _sending_to_channel; // MSG_PICK (shared) int _msg_sel, _msg_scroll; int _active_msgs[QUICK_MSGS_MAX]; int _active_msg_count; // CHANNEL_HIST int _hist_sel, _hist_scroll; FullscreenMsgView _fs; int _unread_at_entry; // _ch_unread value when entering CHANNEL_HIST int _viewing_max_seen; // highest _hist_sel reached in current session static const int CH_HIST_MAX = 32; // KEYBOARD KeyboardWidget _kb; // Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK) PopupMenu _ctx_menu; bool _ctx_dirty; char _ctx_notif_item[22]; char _ctx_melody_item[20]; struct ChHistEntry { uint8_t ch_idx; char text[140]; }; ChHistEntry _hist[CH_HIST_MAX]; int _hist_head, _hist_count; // DM_HIST struct DmHistEntry { uint8_t prefix[4]; uint8_t outgoing; char text[80]; }; static const int DM_HIST_MAX = 64; DmHistEntry _dm_hist[DM_HIST_MAX]; int _dm_hist_head, _dm_hist_count; int _dm_hist_sel, _dm_hist_scroll; FullscreenMsgView _dm_fs; static const int VISIBLE = 4; static const int ITEM_H = 12; static const int START_Y = 12; static const int HIST_VISIBLE = 2; // 2-line boxed history entries static const int HIST_ITEM_H = 21; // box(19) + gap(2) static const int HIST_BOX_H = 19; static const int HIST_START_Y = 11; void expandMsg(const char* tmpl, char* out, int out_len) const { double lat = 0, lon = 0; bool gps_valid = false; #if ENV_INCLUDE_GPS == 1 LocationProvider* loc = sensors.getLocationProvider(); if (loc && loc->isValid()) { lat = loc->getLatitude() / 1000000.0; lon = loc->getLongitude() / 1000000.0; gps_valid = true; } #endif NodePrefs* np = _task->getNodePrefs(); float batt = (float)board.getBattMilliVolts() / 1000.0f; ::expandMsg(tmpl, out, out_len, lat, lon, gps_valid, rtc_clock.getCurrentTime(), np ? np->tz_offset_hours : 0, &sensors, batt); } void setupMsgPick() { _msg_sel = _msg_scroll = 0; _active_msg_count = 0; NodePrefs* p = _task->getNodePrefs(); if (p) { for (int i = 0; i < QUICK_MSGS_MAX; i++) { if (p->custom_msgs[i][0] != '\0') _active_msgs[_active_msg_count++] = i; } } } void renderScrollHints(DisplayDriver& display, int scroll, int count) { display.setColor(DisplayDriver::LIGHT); if (scroll > 0) { display.setCursor(display.width() - 6, START_Y); display.print("^"); } if (scroll + VISIBLE < count) { display.setCursor(display.width() - 6, START_Y + (VISIBLE-1)*ITEM_H); display.print("v"); } } // count history entries for a specific channel int histCountForChannel(int ch_idx) const { int n = 0; for (int i = 0; i < _hist_count; i++) { if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++; } return n; } // get ring-buffer position of j-th history entry for channel (newest first) int histEntryForChannel(int ch_idx, int j) const { int n = 0; for (int i = _hist_count - 1; i >= 0; i--) { int pos = (_hist_head + i) % CH_HIST_MAX; if (_hist[pos].ch_idx == (uint8_t)ch_idx) { if (n == j) return pos; n++; } } return -1; } void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { int pos; if (_dm_hist_count < DM_HIST_MAX) { pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX; _dm_hist_count++; } else { pos = _dm_hist_head; _dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX; } memcpy(_dm_hist[pos].prefix, pub_key, 4); _dm_hist[pos].outgoing = outgoing ? 1 : 0; strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1); _dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0'; } int dmHistCountForContact(const uint8_t* prefix) const { int n = 0; for (int i = 0; i < _dm_hist_count; i++) if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++; return n; } int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest int n = 0; for (int i = _dm_hist_count - 1; i >= 0; i--) { int pos = (_dm_hist_head + i) % DM_HIST_MAX; if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) { if (n == j) return pos; n++; } } return -1; } void afterSend(bool ok, const char* msg) { if (ok && _sending_to_channel) { _hist_sel = 0; _hist_scroll = 0; _phase = CHANNEL_HIST; // set before addChannelMsg so viewing=true, no unread bump char entry[sizeof(ChHistEntry::text)]; snprintf(entry, sizeof(entry), "Me: %s", msg); addChannelMsg(_sel_channel_idx, entry); // After inserting sent msg at index 0, the unread index range is stale. // User is active in this channel — treat as fully read. _ch_unread[_sel_channel_idx] = 0; _unread_at_entry = 0; _viewing_max_seen = 0; _task->showAlert("Sent!", 600); } else if (ok) { storeDMMsg(_sel_contact.id.pub_key, true, msg); _dm_hist_sel = 0; _dm_hist_scroll = 0; _phase = DM_HIST; _task->showAlert("Sent!", 600); } else { _task->showAlert("Send failed", 1500); _task->gotoHomeScreen(); } } bool sendText(const char* msg) { if (_sending_to_channel) { ChannelDetails ch; if (!the_mesh.getChannel(_sel_channel_idx, ch)) return false; return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel, the_mesh.getNodeName(), msg, strlen(msg)); } else { uint32_t expected_ack = 0, est_timeout = 0; return the_mesh.sendMessage(_sel_contact, rtc_clock.getCurrentTime(), 0, msg, expected_ack, est_timeout) > 0; } } void buildContactList() { NodePrefs* p = _task->getNodePrefs(); ContactInfo c; int total = the_mesh.getNumContacts(); _num_contacts = 0; if (_room_mode) { bool fav_only = (p && p->room_fav_only); for (int i = 0; i < total; i++) { if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_ROOM) continue; if (fav_only && !(c.flags & 0x01)) continue; _sorted[_num_contacts++] = i; } } else { bool show_all = (p && p->dm_show_all); for (int i = 0; i < total; i++) { if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; if (!show_all && !(c.flags & 0x01)) continue; _sorted[_num_contacts++] = i; } } } void buildChannelList() { _num_channels = 0; for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { ChannelDetails ch; if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') { _channel_indices[_num_channels++] = (uint8_t)i; } } } // Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on uint8_t chNotifState(uint8_t ch_idx) const { NodePrefs* p = _task->getNodePrefs(); if (!p) return 0; uint64_t mask = 1ULL << ch_idx; if (!(p->ch_notif_override & mask)) return 0; return (p->ch_notif_muted & mask) ? 1 : 2; } void setChNotifState(uint8_t ch_idx, uint8_t state) { NodePrefs* p = _task->getNodePrefs(); if (!p) return; uint64_t mask = 1ULL << ch_idx; if (state == 0) { p->ch_notif_override &= ~mask; p->ch_notif_muted &= ~mask; } else if (state == 1) { p->ch_notif_override |= mask; p->ch_notif_muted |= mask; } else { p->ch_notif_override |= mask; p->ch_notif_muted &= ~mask; } } uint8_t dmNotifState(const uint8_t* pub_key) const { NodePrefs* p = _task->getNodePrefs(); if (!p) return 0; for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) return p->dm_notif[i].state; return 0; } void setDmNotifState(const uint8_t* pub_key, uint8_t state) { NodePrefs* p = _task->getNodePrefs(); if (!p) return; for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) { if (state == 0) { memset(&p->dm_notif[i], 0, sizeof(p->dm_notif[i])); } else p->dm_notif[i].state = state; return; } } if (state == 0) return; for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { if (p->dm_notif[i].state == 0) { memcpy(p->dm_notif[i].prefix, pub_key, 4); p->dm_notif[i].state = state; return; } } // table full — overwrite slot 0 memcpy(p->dm_notif[0].prefix, pub_key, 4); p->dm_notif[0].state = state; } uint8_t chNotifMelody(uint8_t ch_idx) const { NodePrefs* p = _task->getNodePrefs(); if (!p) return 0; uint64_t mask = 1ULL << ch_idx; if (!(p->ch_notif_melody_set & mask)) return 0; return (p->ch_notif_melody_2 & mask) ? 2 : 1; } void setChNotifMelody(uint8_t ch_idx, uint8_t slot) { NodePrefs* p = _task->getNodePrefs(); if (!p) return; uint64_t mask = 1ULL << ch_idx; if (slot == 0) { p->ch_notif_melody_set &= ~mask; p->ch_notif_melody_2 &= ~mask; } else if (slot == 1) { p->ch_notif_melody_set |= mask; p->ch_notif_melody_2 &= ~mask; } else { p->ch_notif_melody_set |= mask; p->ch_notif_melody_2 |= mask; } } uint8_t dmMelodySlot(const uint8_t* pub_key) const { NodePrefs* p = _task->getNodePrefs(); if (!p) return 0; for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) return p->dm_melody[i].slot; return 0; } void setDmMelody(const uint8_t* pub_key, uint8_t slot) { NodePrefs* p = _task->getNodePrefs(); if (!p) return; for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) { if (slot == 0) memset(&p->dm_melody[i], 0, sizeof(p->dm_melody[i])); else p->dm_melody[i].slot = slot; return; } } if (slot == 0) return; for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { if (p->dm_melody[i].slot == 0) { memcpy(p->dm_melody[i].prefix, pub_key, 4); p->dm_melody[i].slot = slot; return; } } memcpy(p->dm_melody[0].prefix, pub_key, 4); p->dm_melody[0].slot = slot; } public: QuickMsgScreen(UITask* task) : _task(task), _phase(MODE_SELECT), _mode_sel(0), _contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false), _channel_sel(0), _channel_scroll(0), _num_channels(0), _sel_channel_idx(0), _sending_to_channel(false), _msg_sel(0), _msg_scroll(0), _active_msg_count(0), _hist_sel(0), _hist_scroll(0), _unread_at_entry(0), _viewing_max_seen(0), _hist_head(0), _hist_count(0), _dm_hist_head(0), _dm_hist_count(0), _dm_hist_sel(-1), _dm_hist_scroll(0), _ctx_dirty(false) { memset(_ch_unread, 0, sizeof(_ch_unread)); } void addChannelMsg(uint8_t ch_idx, const char* text) { int pos; if (_hist_count < CH_HIST_MAX) { pos = (_hist_head + _hist_count) % CH_HIST_MAX; _hist_count++; } else { pos = _hist_head; _hist_head = (_hist_head + 1) % CH_HIST_MAX; } _hist[pos].ch_idx = ch_idx; strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; if (ch_idx < MAX_GROUP_CHANNELS) { bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx); if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; } } void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { storeDMMsg(pub_key, outgoing, text); } int getDMUnreadTotal() const { return _task->getDMUnreadTotal(); } int getTotalChannelUnread() const { int total = 0; for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i]; return total; } void clearAllChannelUnread() { memset(_ch_unread, 0, sizeof(_ch_unread)); } void updateChannelUnread() { if (_hist_sel < 0 || _sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; if (_hist_sel > _viewing_max_seen) _viewing_max_seen = _hist_sel; // histEntryForChannel is newest-first: index 0 = newest (unread), higher = older. // Each step down from 0 sees one more message; seen count = max_seen + 1. int remaining = _unread_at_entry - (_viewing_max_seen + 1); _ch_unread[_sel_channel_idx] = (uint8_t)(remaining > 0 ? remaining : 0); } void reset() { _phase = MODE_SELECT; _mode_sel = 0; _contact_sel = _contact_scroll = 0; _msg_sel = _msg_scroll = 0; _channel_sel = _channel_scroll = 0; _sending_to_channel = false; _room_mode = false; buildContactList(); buildChannelList(); _ctx_menu.active = false; _ctx_dirty = false; _unread_at_entry = 0; _viewing_max_seen = 0; } int render(DisplayDriver& display) override { display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); if (_phase == MODE_SELECT) { display.drawTextCentered(display.width()/2, 0, "MESSAGE"); display.fillRect(0, 10, display.width(), 1); const char* opts[] = { "Direct message", "Channels", "Room Servers" }; int badges[3] = { getDMUnreadTotal(), _task->getChannelUnreadCount(), _task->getRoomUnreadCount() }; for (int i = 0; i < 3; i++) { int y = START_Y + i * ITEM_H; bool sel = (i == _mode_sel); if (sel) { display.setColor(DisplayDriver::LIGHT); display.fillRect(0, y - 1, display.width(), ITEM_H - 1); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } display.setCursor(0, y); display.print(sel ? ">" : " "); display.setCursor(8, y); display.print(opts[i]); if (badges[i] > 0) { char badge[5]; snprintf(badge, sizeof(badge), "%d", badges[i]); int bw = display.getTextWidth(badge) + 2; display.setCursor(display.width() - bw, y); display.print(badge); } } display.setColor(DisplayDriver::LIGHT); } else if (_phase == CONTACT_PICK) { display.drawTextCentered(display.width()/2, 0, _room_mode ? "SELECT ROOM" : "SELECT CONTACT"); display.fillRect(0, 10, display.width(), 1); if (_num_contacts == 0) { display.drawTextCentered(display.width()/2, 32, _room_mode ? "No room servers" : "No favourites"); return 5000; } for (int i = 0; i < VISIBLE && (_contact_scroll+i) < _num_contacts; i++) { int list_idx = _contact_scroll + i; int mesh_idx = _sorted[list_idx]; bool sel = (list_idx == _contact_sel); int y = START_Y + i * ITEM_H; if (sel) { display.setColor(DisplayDriver::LIGHT); display.fillRect(0, y - 1, display.width(), ITEM_H - 1); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } ContactInfo c; if (the_mesh.getContactByIdx(mesh_idx, c)) { display.setCursor(0, y); display.print(sel ? ">" : " "); char filtered[sizeof(c.name)]; display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered)); uint8_t dm_unread = _task->getDMUnread(c.id.pub_key); char badge[5] = ""; int bw = 0; if (dm_unread > 0) { snprintf(badge, sizeof(badge), "%d", (int)dm_unread); bw = display.getTextWidth(badge) + 2; } display.drawTextEllipsized(8, y, display.width() - 8 - bw - 1, filtered); if (dm_unread > 0) { display.setCursor(display.width() - bw + 1, y); display.print(badge); } } } display.setColor(DisplayDriver::LIGHT); renderScrollHints(display, _contact_scroll, _num_contacts); // Context menu overlay if (_ctx_menu.active) _ctx_menu.render(display); } else if (_phase == CHANNEL_PICK) { display.drawTextCentered(display.width()/2, 0, "SELECT CHANNEL"); display.fillRect(0, 10, display.width(), 1); if (_num_channels == 0) { display.drawTextCentered(display.width()/2, 32, "No channels"); return 5000; } for (int i = 0; i < VISIBLE && (_channel_scroll+i) < _num_channels; i++) { int list_idx = _channel_scroll + i; bool sel = (list_idx == _channel_sel); int y = START_Y + i * ITEM_H; if (sel) { display.setColor(DisplayDriver::LIGHT); display.fillRect(0, y - 1, display.width(), ITEM_H - 1); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } display.setCursor(0, y); display.print(sel ? ">" : " "); ChannelDetails ch; if (the_mesh.getChannel(_channel_indices[list_idx], ch)) { uint8_t unread = _ch_unread[_channel_indices[list_idx]]; char badge[5] = ""; int bw = 0; if (unread > 0) { snprintf(badge, sizeof(badge), "%d", (int)unread); bw = display.getTextWidth(badge) + 2; } display.drawTextEllipsized(8, y, display.width() - 10 - bw, ch.name); if (unread > 0) { display.setCursor(display.width() - bw, y); display.print(badge); } } } display.setColor(DisplayDriver::LIGHT); renderScrollHints(display, _channel_scroll, _num_channels); // Context menu overlay if (_ctx_menu.active) _ctx_menu.render(display); } else if (_phase == DM_HIST) { display.setTextSize(1); char filtered_name[sizeof(_sel_contact.name)]; display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name)); if (_dm_fs.active && _dm_hist_sel >= 0) { int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); if (ring_pos >= 0) { const DmHistEntry& e = _dm_hist[ring_pos]; const char* sender = e.outgoing ? "Me" : filtered_name; int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); return _dm_fs.render(display, sender, e.text, _dm_hist_sel < dm_count - 1, _dm_hist_sel > 0); } return 500; } char title[24]; display.setColor(DisplayDriver::LIGHT); snprintf(title, sizeof(title), "%.23s", filtered_name); display.drawTextCentered(display.width()/2, 0, title); display.fillRect(0, 9, display.width(), 1); int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); for (int i = 0; i < HIST_VISIBLE && (_dm_hist_scroll + i) < dm_count; i++) { int item = _dm_hist_scroll + i; bool sel = (item == _dm_hist_sel); int y = HIST_START_Y + i * HIST_ITEM_H; int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, item); if (ring_pos < 0) continue; const DmHistEntry& e = _dm_hist[ring_pos]; const char* sender = e.outgoing ? "Me" : filtered_name; if (sel) { display.setColor(DisplayDriver::LIGHT); display.fillRect(0, y, display.width(), HIST_BOX_H); display.setColor(DisplayDriver::DARK); display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); } else { display.setColor(DisplayDriver::LIGHT); display.drawRect(0, y, display.width(), HIST_BOX_H); display.fillRect(1, y + 1, display.width() - 2, 8); display.setColor(DisplayDriver::DARK); display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); display.setColor(DisplayDriver::LIGHT); display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); } } if (dm_count == 0) { display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(display.width()/2, 32, "No messages yet"); } display.setColor(DisplayDriver::LIGHT); if (_dm_hist_scroll > 0) { display.setCursor(display.width() - 6, HIST_START_Y + 1); display.print("^"); } if (_dm_hist_scroll + HIST_VISIBLE < dm_count) { display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10); display.print("v"); } bool compose_sel = (_dm_hist_sel == -1); const char* ctxt = "[+ send]"; int ctw = display.getTextWidth(ctxt); int cbx = 1, cby = 55; if (compose_sel) { display.fillRect(cbx, cby - 1, ctw + 4, 9); display.setColor(DisplayDriver::DARK); } display.setCursor(cbx + 2, cby); display.print(ctxt); display.setColor(DisplayDriver::LIGHT); return dm_count > 0 ? 500 : 2000; } else if (_phase == CHANNEL_HIST) { if (_fs.active && _hist_sel >= 0) { int fs_hist_count = histCountForChannel(_sel_channel_idx); int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); if (ring_pos >= 0) { const char* ftext = _hist[ring_pos].text; const char* fsep = strstr(ftext, ": "); char fsender[22], fmsg[140]; if (fsep) { int nl = fsep - ftext; if (nl > 21) nl = 21; strncpy(fsender, ftext, nl); fsender[nl] = '\0'; strncpy(fmsg, fsep + 2, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; } else { strncpy(fsender, "?", sizeof(fsender)); strncpy(fmsg, ftext, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; } return _fs.render(display, fsender, fmsg, _hist_sel < fs_hist_count - 1, _hist_sel > 0); } return 300; } ChannelDetails ch; the_mesh.getChannel(_sel_channel_idx, ch); char title[24]; snprintf(title, sizeof(title), "%.23s", ch.name); display.drawTextCentered(display.width()/2, 0, title); display.fillRect(0, 9, display.width(), 1); int ch_hist_count = histCountForChannel(_sel_channel_idx); for (int i = 0; i < HIST_VISIBLE && (_hist_scroll + i) < ch_hist_count; i++) { int item = _hist_scroll + i; bool sel = (item == _hist_sel); int y = HIST_START_Y + i * HIST_ITEM_H; int ring_pos = histEntryForChannel(_sel_channel_idx, item); if (ring_pos < 0) continue; const char* text = _hist[ring_pos].text; const char* sep = strstr(text, ": "); char sender[22], msg_part[79]; if (sep) { int nl = sep - text; if (nl > 21) nl = 21; strncpy(sender, text, nl); sender[nl] = '\0'; strncpy(msg_part, sep + 2, sizeof(msg_part) - 1); msg_part[sizeof(msg_part) - 1] = '\0'; } else { strncpy(sender, "?", sizeof(sender)); strncpy(msg_part, text, sizeof(msg_part) - 1); msg_part[sizeof(msg_part) - 1] = '\0'; } if (sel) { display.setColor(DisplayDriver::LIGHT); display.fillRect(0, y, display.width(), HIST_BOX_H); display.setColor(DisplayDriver::DARK); display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); } else { display.setColor(DisplayDriver::LIGHT); display.drawRect(0, y, display.width(), HIST_BOX_H); display.fillRect(1, y + 1, display.width() - 2, 8); display.setColor(DisplayDriver::DARK); display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); display.setColor(DisplayDriver::LIGHT); display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); } } if (ch_hist_count == 0) { display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(display.width()/2, 32, "No messages yet"); } // scroll hints display.setColor(DisplayDriver::LIGHT); if (_hist_scroll > 0) { display.setCursor(display.width() - 6, HIST_START_Y + 1); display.print("^"); } if (_hist_scroll + HIST_VISIBLE < ch_hist_count) { display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10); display.print("v"); } // small compose button (bottom-left, always bordered, inverted when selected) bool compose_sel = (_hist_sel == -1); const char* ctxt = "[+ send]"; int ctw = display.getTextWidth(ctxt); int cbx = 1, cby = 55; if (compose_sel) { display.setColor(DisplayDriver::LIGHT); display.fillRect(cbx - 1, cby - 1, ctw + 4, 10); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); display.drawRect(cbx - 1, cby - 1, ctw + 4, 10); } display.setCursor(cbx + 1, cby); display.print(ctxt); display.setColor(DisplayDriver::LIGHT); } else if (_phase == KEYBOARD) { return _kb.render(display); } else { // MSG_PICK char title[24]; if (_sending_to_channel) { ChannelDetails ch; the_mesh.getChannel(_sel_channel_idx, ch); snprintf(title, sizeof(title), "%.23s", ch.name); } else { snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name); } display.drawTextCentered(display.width()/2, 0, title); display.fillRect(0, 10, display.width(), 1); int total_msg_items = 1 + _active_msg_count; for (int i = 0; i < VISIBLE && (_msg_scroll+i) < total_msg_items; i++) { int idx = _msg_scroll + i; bool sel = (idx == _msg_sel); int y = START_Y + i * ITEM_H; if (sel) { display.setColor(DisplayDriver::LIGHT); display.fillRect(0, y - 1, display.width(), ITEM_H - 1); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } display.setCursor(0, y); display.print(sel ? ">" : " "); if (idx == 0) { display.setCursor(8, y); display.print("Custom message..."); } else { NodePrefs* p = _task->getNodePrefs(); int slot = _active_msgs[idx - 1]; const char* tmpl = p ? p->custom_msgs[slot] : ""; display.drawTextEllipsized(8, y, display.width() - 10, tmpl); } } display.setColor(DisplayDriver::LIGHT); renderScrollHints(display, _msg_scroll, total_msg_items); } return 300; } bool handleInput(char c) override { if (_phase == MODE_SELECT) { if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; } if (c == KEY_UP && _mode_sel > 0) { _mode_sel--; return true; } if (c == KEY_DOWN && _mode_sel < 2) { _mode_sel++; return true; } if (c == KEY_ENTER) { if (_mode_sel == 1) { _phase = CHANNEL_PICK; } else { _room_mode = (_mode_sel == 2); if (_room_mode) _task->clearRoomUnread(); buildContactList(); _contact_sel = _contact_scroll = 0; _phase = CONTACT_PICK; } return true; } } else if (_phase == CONTACT_PICK) { // Context menu consumes all input while open if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); if (res == PopupMenu::SELECTED && _num_contacts > 0) { ContactInfo ci; if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { if (_ctx_menu.selectedIndex() == 0) { _task->clearDMUnread(ci.id.pub_key); } else if (_ctx_menu.selectedIndex() == 1) { uint8_t nstate = dmNotifState(ci.id.pub_key); setDmNotifState(ci.id.pub_key, (nstate + 1) % 3); _ctx_dirty = true; } else { uint8_t slot = dmMelodySlot(ci.id.pub_key); setDmMelody(ci.id.pub_key, (slot + 1) % 3); _ctx_dirty = true; } } } if (res != PopupMenu::NONE && _ctx_dirty) { the_mesh.savePrefs(); _ctx_dirty = false; } return true; } if (c == KEY_CANCEL) { _room_mode = false; _phase = MODE_SELECT; return true; } if (c == KEY_UP && _contact_sel > 0) { _contact_sel--; if (_contact_sel < _contact_scroll) _contact_scroll = _contact_sel; return true; } if (c == KEY_DOWN && _contact_sel < _num_contacts - 1) { _contact_sel++; if (_contact_sel >= _contact_scroll + VISIBLE) _contact_scroll = _contact_sel - VISIBLE + 1; return true; } if (c == KEY_ENTER && _num_contacts > 0) { if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { _task->clearDMUnread(_sel_contact.id.pub_key); _dm_hist_sel = -1; _dm_hist_scroll = 0; _dm_fs.active = false; _phase = DM_HIST; } return true; } if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) { static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; ContactInfo ci; the_mesh.getContactByIdx(_sorted[_contact_sel], ci); snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", NOTIF_LABELS[dmNotifState(ci.id.pub_key)]); { static const char* ML[] = { "global", "M1", "M2" }; snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", ML[dmMelodySlot(ci.id.pub_key)]); } _ctx_menu.begin("Contact options", 3); _ctx_menu.addItem("Mark as read"); _ctx_menu.addItem(_ctx_notif_item); _ctx_menu.addItem(_ctx_melody_item); _ctx_dirty = false; return true; } } else if (_phase == CHANNEL_PICK) { // Context menu consumes all input while open if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); if (res == PopupMenu::SELECTED && _num_channels > 0) { uint8_t ch_idx = _channel_indices[_channel_sel]; if (_ctx_menu.selectedIndex() == 0) { _ch_unread[ch_idx] = 0; } else if (_ctx_menu.selectedIndex() == 1) { uint8_t nstate = chNotifState(ch_idx); setChNotifState(ch_idx, (nstate + 1) % 3); _ctx_dirty = true; } else { uint8_t slot = chNotifMelody(ch_idx); setChNotifMelody(ch_idx, (slot + 1) % 3); _ctx_dirty = true; } } if (res != PopupMenu::NONE && _ctx_dirty) { the_mesh.savePrefs(); _ctx_dirty = false; } return true; } if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; } if (c == KEY_UP && _channel_sel > 0) { _channel_sel--; if (_channel_sel < _channel_scroll) _channel_scroll = _channel_sel; return true; } if (c == KEY_DOWN && _channel_sel < _num_channels - 1) { _channel_sel++; if (_channel_sel >= _channel_scroll + VISIBLE) _channel_scroll = _channel_sel - VISIBLE + 1; return true; } if (c == KEY_ENTER && _num_channels > 0) { _sel_channel_idx = _channel_indices[_channel_sel]; int hc = histCountForChannel(_sel_channel_idx); _unread_at_entry = (int)_ch_unread[_sel_channel_idx]; _hist_scroll = 0; _hist_sel = hc > 0 ? 0 : -1; _viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0; _phase = CHANNEL_HIST; updateChannelUnread(); return true; } if (c == KEY_CONTEXT_MENU && _num_channels > 0) { uint8_t ch_idx = _channel_indices[_channel_sel]; static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", NOTIF_LABELS[chNotifState(ch_idx)]); { static const char* ML[] = { "global", "M1", "M2" }; snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", ML[chNotifMelody(ch_idx)]); } _ctx_menu.begin("Channel options", 3); _ctx_menu.addItem("Mark all read"); _ctx_menu.addItem(_ctx_notif_item); _ctx_menu.addItem(_ctx_melody_item); _ctx_dirty = false; return true; } } else if (_phase == DM_HIST) { int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); if (_dm_fs.active) { auto res = _dm_fs.handleInput(c); if (res == FullscreenMsgView::PREV) { if (_dm_hist_sel < dm_count - 1) { _dm_hist_sel++; _dm_fs.scroll = 0; } } else if (res == FullscreenMsgView::NEXT) { if (_dm_hist_sel > 0) { _dm_hist_sel--; _dm_fs.scroll = 0; } } else if (res == FullscreenMsgView::CLOSE) { _dm_fs.active = false; } return true; } if (c == KEY_CANCEL) { _phase = CONTACT_PICK; return true; } if (c == KEY_UP) { if (_dm_hist_sel > 0) { _dm_hist_sel--; if (_dm_hist_sel < _dm_hist_scroll) _dm_hist_scroll = _dm_hist_sel; } else if (_dm_hist_sel == 0) { _dm_hist_sel = -1; } return true; } if (c == KEY_DOWN) { if (_dm_hist_sel == -1 && dm_count > 0) { _dm_hist_sel = 0; _dm_hist_scroll = 0; } else if (_dm_hist_sel >= 0 && _dm_hist_sel < dm_count - 1) { _dm_hist_sel++; if (_dm_hist_sel >= _dm_hist_scroll + HIST_VISIBLE) _dm_hist_scroll = _dm_hist_sel - HIST_VISIBLE + 1; } return true; } if (c == KEY_ENTER) { if (_dm_hist_sel >= 0) { _dm_fs.begin(); } else { _sending_to_channel = false; setupMsgPick(); _phase = MSG_PICK; } return true; } } else if (_phase == CHANNEL_HIST) { int ch_hist_count = histCountForChannel(_sel_channel_idx); if (_fs.active) { auto res = _fs.handleInput(c); if (res == FullscreenMsgView::PREV) { if (_hist_sel < ch_hist_count - 1) { _hist_sel++; _fs.scroll = 0; updateChannelUnread(); } } else if (res == FullscreenMsgView::NEXT) { if (_hist_sel > 0) { _hist_sel--; _fs.scroll = 0; updateChannelUnread(); } } else if (res == FullscreenMsgView::CLOSE) { _fs.active = false; } return true; } if (c == KEY_CANCEL) { _phase = CHANNEL_PICK; return true; } if (c == KEY_UP) { if (_hist_sel > 0) { _hist_sel--; if (_hist_sel < _hist_scroll) _hist_scroll = _hist_sel; } else if (_hist_sel == 0) _hist_sel = -1; updateChannelUnread(); return true; } if (c == KEY_DOWN) { if (_hist_sel == -1 && ch_hist_count > 0) { _hist_sel = 0; _hist_scroll = 0; } else if (_hist_sel >= 0 && _hist_sel < ch_hist_count - 1) { _hist_sel++; if (_hist_sel >= _hist_scroll + HIST_VISIBLE) _hist_scroll = _hist_sel - HIST_VISIBLE + 1; } updateChannelUnread(); return true; } if (c == KEY_ENTER) { if (_hist_sel >= 0) { _fs.begin(); updateChannelUnread(); } else { _sending_to_channel = true; setupMsgPick(); _phase = MSG_PICK; } return true; } } else if (_phase == KEYBOARD) { auto res = _kb.handleInput(c); if (res == KeyboardWidget::CANCELLED) { _phase = MSG_PICK; } else if (res == KeyboardWidget::DONE) { if (_kb.len > 0) { char expanded[KB_MAX_LEN + 1]; expandMsg(_kb.buf, expanded, sizeof(expanded)); bool ok = sendText(expanded); afterSend(ok, expanded); } } return true; } else { // MSG_PICK int total_msg_items = 1 + _active_msg_count; if (c == KEY_CANCEL) { _phase = _sending_to_channel ? CHANNEL_HIST : DM_HIST; return true; } if (c == KEY_UP && _msg_sel > 0) { _msg_sel--; if (_msg_sel < _msg_scroll) _msg_scroll = _msg_sel; return true; } if (c == KEY_DOWN && _msg_sel < total_msg_items - 1) { _msg_sel++; if (_msg_sel >= _msg_scroll + VISIBLE) _msg_scroll = _msg_sel - VISIBLE + 1; return true; } if (c == KEY_ENTER) { if (_msg_sel == 0) { _kb.begin(); kbAddSensorPlaceholders(_kb, &sensors); _phase = KEYBOARD; return true; } NodePrefs* p = _task->getNodePrefs(); int slot = _active_msgs[_msg_sel - 1]; const char* tmpl = p ? p->custom_msgs[slot] : "OK"; char msg[80]; expandMsg(tmpl, msg, sizeof(msg)); bool ok = sendText(msg); afterSend(ok, msg); return true; } } return false; } }; // ── Custom screens (separate files to ease upstream merges) ─────────────────── #include "RingtoneEditorScreen.h" #include "BotScreen.h" #include "NearbyScreen.h" #include "DashboardConfigScreen.h" #include "AutoAdvertScreen.h" #include "ToolsScreen.h" // ── HomeScreen ──────────────────────────────────────────────────────────────── class HomeScreen : public UIScreen { enum HomePage { CLOCK, RECENT, RADIO, BLUETOOTH, ADVERT, #if ENV_INCLUDE_GPS == 1 GPS, #endif #if UI_SENSORS_PAGE == 1 SENSORS, #endif SETTINGS, TOOLS, QUICK_MSG, 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]; int pageBit(int page) const { if (page == CLOCK) return 0; if (page == RECENT) return 1; if (page == RADIO) return 2; if (page == BLUETOOTH) return 3; if (page == ADVERT) return 4; #if ENV_INCLUDE_GPS == 1 if (page == GPS) return 5; #endif #if UI_SENSORS_PAGE == 1 if (page == SENSORS) return 6; #endif if (page == TOOLS) return 7; if (page == SHUTDOWN) return 8; return -1; // SETTINGS, QUICK_MSG always visible } bool isPageVisible(int page) const { int bit = pageBit(page); if (bit < 0) return true; uint16_t mask = (_node_prefs && _node_prefs->home_pages_mask) ? _node_prefs->home_pages_mask : HP_ALL; return (mask >> bit) & 1; } int navPage(int from, int dir) const { for (int i = 1; i < (int)Count; i++) { int next = ((from + dir * i) % (int)Count + (int)Count) % (int)Count; if (isPageVisible(next)) return next; } return from; } int renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { #ifndef BATT_MIN_MILLIVOLTS #define BATT_MIN_MILLIVOLTS 3200 #endif // LiPo discharge curve: voltage (mV) → raw capacity (%) static const struct { uint16_t mv; uint8_t pct; } CURVE[] = { {3200, 0}, {3300, 3}, {3400, 8}, {3500, 15}, {3600, 25}, {3650, 33}, {3700, 45}, {3750, 58}, {3800, 68}, {3900, 77}, {4000, 86}, {4100, 93}, {4200, 100} }; static const int CURVE_LEN = sizeof(CURVE) / sizeof(CURVE[0]); auto curveAt = [&](int mv) -> int { if (mv <= (int)CURVE[0].mv) return CURVE[0].pct; if (mv >= (int)CURVE[CURVE_LEN-1].mv) return CURVE[CURVE_LEN-1].pct; for (int i = 1; i < CURVE_LEN; i++) { if (mv <= (int)CURVE[i].mv) { int span_mv = CURVE[i].mv - CURVE[i-1].mv; int span_pct = CURVE[i].pct - CURVE[i-1].pct; return CURVE[i-1].pct + (mv - (int)CURVE[i-1].mv) * span_pct / span_mv; } } return 100; }; int low_mv = (_node_prefs && _node_prefs->low_batt_mv > 0) ? (int)_node_prefs->low_batt_mv : BATT_MIN_MILLIVOLTS; int raw_pct = curveAt((int)batteryMilliVolts); int low_pct = curveAt(low_mv); // rescale so low_mv = 0% and 4200mV = 100% int pct = (low_pct >= 100) ? 0 : (raw_pct - low_pct) * 100 / (100 - low_pct); if (pct < 0) pct = 0; if (pct > 100) pct = 100; uint8_t mode = (_node_prefs && _node_prefs->batt_display_mode < 3) ? _node_prefs->batt_display_mode : 0; display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); int battLeftX; if (mode == 1) { // percent char buf[6]; sprintf(buf, "%d%%", pct); battLeftX = display.width() - display.getTextWidth(buf) - 1; display.setCursor(battLeftX, 0); display.print(buf); } else if (mode == 2) { // voltage char buf[8]; sprintf(buf, "%u.%02uV", batteryMilliVolts / 1000, (batteryMilliVolts % 1000) / 10); battLeftX = display.width() - display.getTextWidth(buf) - 1; display.setCursor(battLeftX, 0); display.print(buf); } else { // icon const int iconWidth = 24, iconHeight = 8; battLeftX = display.width() - iconWidth - 5; display.drawRect(battLeftX, 0, iconWidth, iconHeight); display.fillRect(battLeftX + iconWidth, iconHeight / 4, 3, iconHeight / 2); int fillWidth = (pct * (iconWidth - 4)) / 100; display.fillRect(battLeftX + 2, 2, fillWidth, iconHeight - 4); } #ifdef PIN_BUZZER if (_task->isBuzzerQuiet()) { display.setColor(DisplayDriver::LIGHT); display.drawXbm(battLeftX - 9, 0, muted_icon, 8, 8); } #endif // BT connection indicator (left of muted/battery icons) int leftmostX = battLeftX; if (_task->isSerialEnabled()) { #ifdef PIN_BUZZER int btX = battLeftX - 18; #else int btX = battLeftX - 9; #endif if (_task->hasConnection()) { display.setColor(DisplayDriver::LIGHT); display.fillRect(btX - 1, 0, 7, 7); display.setColor(DisplayDriver::DARK); display.setCursor(btX, 0); display.print("B"); display.setColor(DisplayDriver::LIGHT); } else { display.setColor(DisplayDriver::LIGHT); display.setCursor(btX, 0); display.print("b"); } leftmostX = btX - 1; // "A" indicator — left of BT, blinks 50% duty at 1s period if (_node_prefs && _node_prefs->advert_auto_interval_sec > 0) { int aX = leftmostX - 8; if ((millis() % 4000) < 2000) { display.setColor(DisplayDriver::LIGHT); display.fillRect(aX - 1, 0, 7, 7); display.setColor(DisplayDriver::DARK); display.setCursor(aX, 0); display.print("A"); display.setColor(DisplayDriver::LIGHT); } leftmostX = aX - 1; } } return leftmostX; } 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]; // node name + battery — hidden on CLOCK page (full screen used for dashboard) if (_page != CLOCK) { display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); char filtered_name[sizeof(_node_prefs->node_name)]; display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); int rightEdge = renderBatteryIndicator(display, _task->getBattMilliVolts()); display.setColor(DisplayDriver::LIGHT); display.drawTextEllipsized(0, 0, rightEdge - 2, filtered_name); } // ensure current page is visible (e.g. after settings change) if (!isPageVisible(_page)) _page = navPage(_page, +1); // curr page indicator — hidden on CLOCK page (full screen used for dashboard) if (_page != CLOCK) { int vis_count = 0, curr_vis = 0; for (int i = 0; i < (int)Count; i++) { if (!isPageVisible(i)) continue; if (i == _page) curr_vis = vis_count; vis_count++; } int y = 14; int x = display.width() / 2 - 5 * (vis_count - 1); int vi = 0; for (int i = 0; i < (int)Count; i++) { if (!isPageVisible(i)) continue; if (vi == curr_vis) display.fillRect(x-1, y-1, 3, 3); else display.fillRect(x, y, 1, 1); x += 10; vi++; } } if (_page == HomePage::CLOCK) { uint32_t unix_ts = _rtc->getCurrentTime(); if (unix_ts < 1000000000UL) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 25, "! No time sync"); display.drawTextCentered(display.width() / 2, 40, "Enable GPS or"); display.drawTextCentered(display.width() / 2, 51, "connect app"); } else { int8_t tz = _node_prefs ? _node_prefs->tz_offset_hours : 0; unix_ts += (int32_t)tz * 3600; time_t t = (time_t)unix_ts; struct tm* ti = gmtime(&t); char buf[24]; display.setColor(DisplayDriver::LIGHT); display.setTextSize(2); bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; if (show_sec) sprintf(buf, "%02d:%02d:%02d", ti->tm_hour, ti->tm_min, ti->tm_sec); else sprintf(buf, "%02d:%02d", ti->tm_hour, ti->tm_min); display.drawTextCentered(display.width() / 2, 0, buf); display.setTextSize(1); static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; sprintf(buf, "%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year); display.drawTextCentered(display.width() / 2, 19, buf); display.fillRect(0, 28, display.width(), 1); // dashboard data fields if (_node_prefs) { refresh_sensors(); static const int FIELD_Y[] = { 31, 41, 51 }; for (int fi = 0; fi < 3; fi++) { uint8_t field = _node_prefs->dashboard_fields[fi]; if (field == DASH_NONE) continue; char label[10], val[20]; label[0] = '\0'; val[0] = '\0'; if (field == DASH_BATT) { strcpy(label, "Batt"); uint16_t mv = _task->getBattMilliVolts(); if (mv > 0) snprintf(val, sizeof(val), "%u.%02uV", mv/1000, (mv%1000)/10); else strcpy(val, "--"); } else if (field == DASH_GPS) { strcpy(label, "GPS"); #if ENV_INCLUDE_GPS == 1 LocationProvider* loc = sensors.getLocationProvider(); if (loc && loc->isValid()) snprintf(val, sizeof(val), "%.3f %.3f", loc->getLatitude()/1000000.0f, loc->getLongitude()/1000000.0f); else strcpy(val, "no fix"); #else strcpy(val, "--"); #endif } else if (field == DASH_NODES) { strcpy(label, "Nodes"); snprintf(val, sizeof(val), "%d", the_mesh.getNumContacts()); } else if (field == DASH_MSGS) { strcpy(label, "Msgs"); int unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(); snprintf(val, sizeof(val), "%d", unread); } else { uint8_t lpp_type = 0; switch (field) { case DASH_TEMP: strcpy(label, "Temp"); lpp_type = LPP_TEMPERATURE; break; case DASH_HUM: strcpy(label, "Hum"); lpp_type = LPP_RELATIVE_HUMIDITY; break; case DASH_PRES: strcpy(label, "Pres"); lpp_type = LPP_BAROMETRIC_PRESSURE; break; case DASH_ALT: strcpy(label, "Alt"); lpp_type = LPP_ALTITUDE; break; case DASH_LUX: strcpy(label, "Lux"); lpp_type = LPP_LUMINOSITY; break; case DASH_CO2: strcpy(label, "CO2"); lpp_type = LPP_CONCENTRATION; break; } if (lpp_type) { LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize()); uint8_t ch, type; while (r.readHeader(ch, type)) { if (type == lpp_type) { float v; switch (lpp_type) { case LPP_TEMPERATURE: r.readTemperature(v); snprintf(val, sizeof(val), "%.1f\xf8""C", v); break; case LPP_RELATIVE_HUMIDITY: r.readRelativeHumidity(v); snprintf(val, sizeof(val), "%.0f%%", v); break; case LPP_BAROMETRIC_PRESSURE: r.readPressure(v); snprintf(val, sizeof(val), "%.0fhPa", v); break; case LPP_ALTITUDE: r.readAltitude(v); snprintf(val, sizeof(val), "%.0fm", v); break; case LPP_LUMINOSITY: r.readLuminosity(v); snprintf(val, sizeof(val), "%.0flux", v); break; case LPP_CONCENTRATION: r.readConcentration(v); snprintf(val, sizeof(val), "%.0fppm", v); break; } break; } r.skipData(type); } } if (!val[0]) strcpy(val, "--"); } if (val[0] && label[0]) { display.setColor(DisplayDriver::LIGHT); display.setCursor(0, FIELD_Y[fi]); display.print(label); int vw = display.getTextWidth(val); display.setCursor(display.width() - vw - 1, FIELD_Y[fi]); display.print(val); } } } } } else if (_page == HomePage::RECENT) { the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE); display.setColor(DisplayDriver::LIGHT); int y = 20; 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::LIGHT); display.setTextSize(1); // freq / sf display.setCursor(0, 20); sprintf(tmp, "FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf); display.print(tmp); display.setCursor(0, 31); sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); display.print(tmp); // tx power, noise floor display.setCursor(0, 42); sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm); display.print(tmp); display.setCursor(0, 53); sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor()); display.print(tmp); } else if (_page == HomePage::BLUETOOTH) { display.setColor(DisplayDriver::LIGHT); display.drawXbm((display.width() - 32) / 2, 14, _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, 32, 32); display.setTextSize(1); if (_task->isSerialEnabled() && !_task->hasConnection() && the_mesh.getBLEPin() != 0) { char pin_buf[16]; snprintf(pin_buf, sizeof(pin_buf), "PIN: %d", the_mesh.getBLEPin()); display.drawTextCentered(display.width() / 2, 49, pin_buf); } display.drawTextCentered(display.width() / 2, 57, "toggle: " PRESS_LABEL); } else if (_page == HomePage::ADVERT) { display.setColor(DisplayDriver::LIGHT); display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32); display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL); #if ENV_INCLUDE_GPS == 1 } else if (_page == HomePage::GPS) { LocationProvider* nmea = sensors.getLocationProvider(); char buf[50]; int y = 18; 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 + 12; display.drawTextLeftAlign(0, y, "Can't access GPS"); } else { strcpy(buf, nmea->isValid()?"fix":"no fix"); display.drawTextRightAlign(display.width()-1, y, buf); y = y + 12; display.drawTextLeftAlign(0, y, "sat"); sprintf(buf, "%d", nmea->satellitesCount()); display.drawTextRightAlign(display.width()-1, y, buf); y = y + 12; display.drawTextLeftAlign(0, y, "pos"); sprintf(buf, "%.4f %.4f", nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.); display.drawTextRightAlign(display.width()-1, y, buf); y = y + 12; display.drawTextLeftAlign(0, y, "alt"); sprintf(buf, "%.2f", nmea->getAltitude()/1000.); display.drawTextRightAlign(display.width()-1, y, buf); y = y + 12; } #endif #if UI_SENSORS_PAGE == 1 } else if (_page == HomePage::SENSORS) { int y = 18; refresh_sensors(); uint8_t avail_types[16]; int avail_count = _sensors ? _sensors->getAvailableLPPTypes(avail_types, 16) : 0; bool need_scroll = avail_count > UI_RECENT_LIST_SIZE; int offset = need_scroll ? (sensors_scroll_offset % avail_count) : 0; int show_n = need_scroll ? UI_RECENT_LIST_SIZE : avail_count; for (int i = 0; i < show_n; i++) { uint8_t target = avail_types[(offset + i) % avail_count]; // scan LPP buffer for this type LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize()); uint8_t ch, type; bool found = false; char buf[22] = "--"; while (r.readHeader(ch, type)) { if (type == target) { float v, v2, v3; switch (type) { case LPP_GPS: r.readGPS(v, v2, v3); if (v != 0 || v2 != 0) snprintf(buf, sizeof(buf), "%.4f %.4f", v, v2); break; case LPP_VOLTAGE: r.readVoltage(v); snprintf(buf, sizeof(buf), "%.2fV", v); break; case LPP_CURRENT: r.readCurrent(v); snprintf(buf, sizeof(buf), "%.3fA", v); break; case LPP_POWER: r.readPower(v); snprintf(buf, sizeof(buf), "%.1fW", v); break; case LPP_TEMPERATURE:r.readTemperature(v); snprintf(buf, sizeof(buf), "%.1f\xf8""C", v); break; case LPP_RELATIVE_HUMIDITY: r.readRelativeHumidity(v); snprintf(buf, sizeof(buf), "%.0f%%", v); break; case LPP_BAROMETRIC_PRESSURE: r.readPressure(v); snprintf(buf, sizeof(buf), "%.1fhPa", v); break; case LPP_ALTITUDE: r.readAltitude(v); snprintf(buf, sizeof(buf), "%.0fm", v); break; case LPP_LUMINOSITY: r.readLuminosity(v); snprintf(buf, sizeof(buf), "%.0flux", v); break; case LPP_PERCENTAGE: r.readPercentage(v); snprintf(buf, sizeof(buf), "%.0f%%", v); break; case LPP_DISTANCE: r.readDistance(v); snprintf(buf, sizeof(buf), "%.2fm", v); break; case LPP_CONCENTRATION: r.readConcentration(v); snprintf(buf, sizeof(buf), "%.0fppm", v); break; default: r.skipData(type); continue; } found = true; break; } r.skipData(type); } (void)found; static const struct { uint8_t type; const char* name; } TYPE_NAMES[] = { { LPP_VOLTAGE, "voltage" }, { LPP_GPS, "gps" }, { LPP_TEMPERATURE, "temp" }, { LPP_RELATIVE_HUMIDITY, "humidity" }, { LPP_BAROMETRIC_PRESSURE,"pressure" }, { LPP_ALTITUDE, "altitude" }, { LPP_CURRENT, "current" }, { LPP_POWER, "power" }, { LPP_LUMINOSITY, "light" }, { LPP_PERCENTAGE, "moisture" }, { LPP_DISTANCE, "distance" }, { LPP_CONCENTRATION, "CO2" }, }; const char* name = "sensor"; for (auto& tn : TYPE_NAMES) { if (tn.type == target) { name = tn.name; break; } } display.setCursor(0, y); display.print(name); display.setCursor(display.width() - display.getTextWidth(buf) - 1, y); display.print(buf); y += 12; } if (need_scroll) sensors_scroll_offset = (sensors_scroll_offset + 1) % avail_count; else sensors_scroll_offset = 0; #endif } else if (_page == HomePage::SETTINGS) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 22, "Settings"); display.drawTextCentered(display.width() / 2, 50, PRESS_LABEL " to open"); } else if (_page == HomePage::TOOLS) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 22, "Tools"); display.drawTextCentered(display.width() / 2, 50, PRESS_LABEL " to open"); } else if (_page == HomePage::QUICK_MSG) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 22, "Messages"); int total_unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(); if (total_unread > 0) { char badge[20]; snprintf(badge, sizeof(badge), "%d unread", total_unread); display.drawTextCentered(display.width() / 2, 35, badge); } display.drawTextCentered(display.width() / 2, 50, PRESS_LABEL " to open"); } else if (_page == HomePage::SHUTDOWN) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); if (_shutdown_init) { display.drawTextCentered(display.width() / 2, 34, "hibernating..."); } else { display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32); display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL); } } bool auto_adv = _node_prefs && _node_prefs->advert_auto_interval_sec > 0; if (_page == HomePage::CLOCK) { bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; return auto_adv ? 1000 : (show_sec ? 1000 : 60000); } return auto_adv ? 1000 : 5000; } bool handleInput(char c) override { if (c == KEY_LEFT || c == KEY_PREV) { _page = navPage(_page, -1); return true; } if (c == KEY_NEXT || c == KEY_RIGHT) { _page = navPage(_page, +1); 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::SETTINGS) { _task->gotoSettingsScreen(); return true; } if (c == KEY_ENTER && _page == HomePage::TOOLS) { _task->gotoToolsScreen(); return true; } if (c == KEY_ENTER && _page == HomePage::QUICK_MSG) { _task->gotoQuickMsgScreen(); return true; } if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) { _shutdown_init = true; // need to wait for button to be released return true; } if (c == KEY_CONTEXT_MENU && _page == HomePage::CLOCK) { _task->gotoDashboardConfig(); return true; } return false; } }; void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) { _display = display; _sensors = sensors; _node_prefs = node_prefs; uint32_t aoff = autoOffMillis(); _auto_off = millis() + (aoff > 0 ? aoff : AUTO_OFF_MILLIS); #if defined(PIN_USER_BTN) user_btn.begin(); #endif #if defined(PIN_USER_BTN_ANA) analog_btn.begin(); #endif if (_display != NULL) { _display->turnOn(); } #ifdef PIN_BUZZER buzzer.quiet(_node_prefs->buzzer_quiet); buzzer.setVolume(_node_prefs->buzzer_volume); buzzer.begin(); #endif #ifdef PIN_VIBRATION vibration.begin(); #endif // Set default quick message if slot 0 is empty (first boot) if (_node_prefs && _node_prefs->custom_msgs[0][0] == '\0') { strncpy(_node_prefs->custom_msgs[0], "OK", sizeof(_node_prefs->custom_msgs[0]) - 1); } ui_started_at = millis(); _alert_expiry = 0; _batt_mv = AbstractUITask::getBattMilliVolts(); // seed EMA with first reading splash = new SplashScreen(this); home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); settings = new SettingsScreen(this); quick_msg = new QuickMsgScreen(this); tools_screen = new ToolsScreen(this); ringtone_edit = new RingtoneEditorScreen(this, node_prefs); bot_screen = new BotScreen(this, node_prefs); nearby_screen = new NearbyScreen(this); dashboard_config = new DashboardConfigScreen(this, node_prefs); auto_advert_screen = new AutoAdvertScreen(this, node_prefs); setCurrScreen(splash); applyBrightness(); } void UITask::gotoSettingsScreen() { ((SettingsScreen*)settings)->markClean(); setCurrScreen(settings); } void UITask::gotoToolsScreen() { setCurrScreen(tools_screen); } void UITask::gotoRingtoneEditor(int slot) { ((RingtoneEditorScreen*)ringtone_edit)->enter(slot); setCurrScreen(ringtone_edit); } void UITask::gotoBotScreen() { ((BotScreen*)bot_screen)->enter(); setCurrScreen(bot_screen); } void UITask::gotoNearbyScreen() { ((NearbyScreen*)nearby_screen)->enter(); setCurrScreen(nearby_screen); } void UITask::gotoDashboardConfig() { ((DashboardConfigScreen*)dashboard_config)->enter(); setCurrScreen(dashboard_config); } void UITask::gotoAutoAdvertScreen() { ((AutoAdvertScreen*)auto_advert_screen)->enter(); setCurrScreen(auto_advert_screen); } void UITask::playMelody(const char* melody) { #ifdef PIN_BUZZER buzzer.playForced(melody); #endif } void UITask::stopMelody() { #ifdef PIN_BUZZER buzzer.stop(); #endif } bool UITask::isMelodyPlaying() { #ifdef PIN_BUZZER return buzzer.isPlaying(); #else return false; #endif } void UITask::gotoQuickMsgScreen() { ((QuickMsgScreen*)quick_msg)->reset(); setCurrScreen(quick_msg); } void UITask::addChannelMsg(uint8_t channel_idx, const char* text) { _last_notif_ch_idx = (int)channel_idx; ((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text); } int UITask::getChannelUnreadCount() const { return ((QuickMsgScreen*)quick_msg)->getTotalChannelUnread(); } void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { ((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text); } int UITask::getDMUnreadTotal() const { int total = 0; for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) total += _dm_unread_table[i].count; return total; } void UITask::showAlert(const char* text, int duration_millis) { snprintf(_alert, sizeof(_alert), "%s", text); _alert_expiry = millis() + duration_millis; } static void buildMelodyFromPrefs(const NodePrefs* p, int slot, char* buf, int size) { static const uint16_t BPM_OPTS[] = { 60, 90, 120, 150, 180 }; static const uint8_t DUR_VALS[] = { 4, 8, 16, 32 }; static const char PITCHES[] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' }; const uint8_t* notes = (slot == 2) ? p->ringtone2_notes : p->ringtone_notes; uint8_t len = (slot == 2) ? p->ringtone2_len : p->ringtone_len; uint8_t bpm_i = (slot == 2) ? p->ringtone2_bpm_idx : p->ringtone_bpm_idx; if (len == 0) { buf[0] = '\0'; return; } uint16_t bpm = BPM_OPTS[bpm_i < 5 ? bpm_i : 2]; int pos = snprintf(buf, size, "Ring:d=8,o=5,b=%u:", bpm); for (int i = 0; i < len && pos < size - 8; i++) { if (i > 0 && pos < size - 1) buf[pos++] = ','; uint8_t pitch = notes[i] & 0x07; uint8_t octave = ((notes[i] >> 3) & 0x03) + 4; uint8_t dur_val = DUR_VALS[(notes[i] >> 5) & 0x03]; if (pitch == 0) pos += snprintf(buf + pos, size - pos, "%dp", dur_val); else pos += snprintf(buf + pos, size - pos, "%d%c%d", dur_val, PITCHES[pitch], octave); } if (pos < size) buf[pos] = '\0'; } void UITask::notify(UIEventType t) { #if defined(PIN_BUZZER) switch(t){ case UIEventType::contactMessage: { bool play = false; bool force = false; if (_last_notif_dm_valid && _node_prefs) { uint8_t state = 0; for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { if (_node_prefs->dm_notif[i].state && memcmp(_node_prefs->dm_notif[i].prefix, _last_notif_dm_prefix, 4) == 0) { state = _node_prefs->dm_notif[i].state; break; } } if (state == 2) { play = true; force = true; } // force-on else if (state == 1) { /* muted */ } else { play = !buzzer.isQuiet(); } // default: follow global } else { play = !buzzer.isQuiet(); } _last_notif_dm_valid = false; if (play) { int slot = _node_prefs ? (int)_node_prefs->notif_melody_dm : 0; if (_node_prefs) { for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) if (_node_prefs->dm_melody[i].slot && memcmp(_node_prefs->dm_melody[i].prefix, _last_notif_dm_prefix, 4) == 0) { slot = _node_prefs->dm_melody[i].slot; break; } } bool custom_played = false; if (slot > 0 && _node_prefs) { buildMelodyFromPrefs(_node_prefs, slot, _notif_mel_buf, sizeof(_notif_mel_buf)); if (_notif_mel_buf[0]) { if (force) buzzer.playForced(_notif_mel_buf); else buzzer.play(_notif_mel_buf); custom_played = true; } } if (!custom_played) { if (force) buzzer.playForced("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); else buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); } } break; } case UIEventType::channelMessage: { bool play = false; bool force = false; if (_last_notif_ch_idx >= 0 && _last_notif_ch_idx < 64 && _node_prefs) { uint64_t mask = 1ULL << _last_notif_ch_idx; if (_node_prefs->ch_notif_override & mask) { if (!(_node_prefs->ch_notif_muted & mask)) { play = true; force = true; } } else { play = !buzzer.isQuiet(); } } else { play = !buzzer.isQuiet(); } if (play) { int slot = _node_prefs ? (int)_node_prefs->notif_melody_ch : 0; if (_last_notif_ch_idx >= 0 && _last_notif_ch_idx < 64 && _node_prefs) { uint64_t mask = 1ULL << _last_notif_ch_idx; if (_node_prefs->ch_notif_melody_set & mask) slot = (_node_prefs->ch_notif_melody_2 & mask) ? 2 : 1; } bool custom_played = false; if (slot > 0 && _node_prefs) { buildMelodyFromPrefs(_node_prefs, slot, _notif_mel_buf, sizeof(_notif_mel_buf)); if (_notif_mel_buf[0]) { if (force) buzzer.playForced(_notif_mel_buf); else buzzer.play(_notif_mel_buf); custom_played = true; } } if (!custom_played) { if (force) buzzer.playForced("kerplop:d=16,o=6,b=120:32g#,32c#"); else buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#"); } } _last_notif_ch_idx = -1; 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) { _room_unread = 0; memset(_dm_unread_table, 0, sizeof(_dm_unread_table)); ((QuickMsgScreen*)quick_msg)->clearAllChannelUnread(); } } void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type, const uint8_t* pub_key) { _msgcount = msgcount; if (contact_type == ADV_TYPE_ROOM && _room_unread < _msgcount) _room_unread++; if (contact_type == ADV_TYPE_CHAT && pub_key != nullptr) { memcpy(_last_notif_dm_prefix, pub_key, 4); _last_notif_dm_valid = true; int slot = -1, empty_slot = -1; for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) { if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0) { slot = i; break; } if (empty_slot < 0 && _dm_unread_table[i].count == 0) empty_slot = i; } if (slot >= 0) { if (_dm_unread_table[slot].count < 99) _dm_unread_table[slot].count++; } else if (empty_slot >= 0) { memcpy(_dm_unread_table[empty_slot].prefix, pub_key, 4); _dm_unread_table[empty_slot].count = 1; } } char alert_buf[80]; snprintf(alert_buf, sizeof(alert_buf), "Msg: %.20s", from_name); showAlert(alert_buf, 3000); if (_display != NULL) { if (!_display->isOn() && !hasConnection()) { _display->turnOn(); } if (_display->isOn()) { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; _next_refresh = 100; } } } 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){ the_mesh.saveRTCTime(); #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() - buzzer_timer) < 2500) 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 } #if UI_HAS_JOYSTICK_UPDOWN ev = joystick_up.check(); if (ev == BUTTON_EVENT_CLICK) { c = checkDisplayOn(KEY_UP); } ev = joystick_down.check(); if (ev == BUTTON_EVENT_CLICK) { c = checkDisplayOn(KEY_DOWN); } #endif 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_CLICK) { c = checkDisplayOn(KEY_CANCEL); } else 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) { 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); { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer _next_refresh = 100; // trigger refresh } userLedHandler(); #ifdef PIN_BUZZER if (_node_prefs && _node_prefs->buzzer_auto) { bool should_quiet = hasConnection(); if (buzzer.isQuiet() != should_quiet) { buzzer.quiet(should_quiet); _next_refresh = 0; } } if (buzzer.isPlaying()) buzzer.loop(); #endif if (curr) curr->poll(); if (_display != NULL && _display->isOn()) { if (millis() >= _next_refresh && curr) { _display->startFrame(); int delay_millis = curr->render(*_display); if (millis() < _alert_expiry && curr == home) { // render alert only on home screen _display->setTextSize(1); int y = _display->height() / 3; int p = _display->height() / 32; _display->setColor(DisplayDriver::DARK); _display->fillRect(p, y, _display->width() - p*2, y); _display->setColor(DisplayDriver::LIGHT); // draw box border _display->drawRect(p, y, _display->width() - p*2, y); _display->drawTextCentered(_display->width() / 2, y + p*3, _alert); _next_refresh = _alert_expiry; // will need refresh when alert is dismissed } else { _next_refresh = millis() + delay_millis; } _display->endFrame(); } #if AUTO_OFF_MILLIS > 0 if (autoOffMillis() > 0 && millis() > _auto_off) { _display->turnOff(); #ifdef PIN_LED digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power #endif } #endif } #ifdef PIN_VIBRATION vibration.loop(); #endif if (millis() > next_batt_chck) { uint16_t raw = AbstractUITask::getBattMilliVolts(); if (raw > 0) { // EMA filter: alpha=0.2 (80% old, 20% new) — smooths ADC noise from uneven load _batt_mv = (_batt_mv == 0) ? raw : (uint16_t)((_batt_mv * 4u + raw) / 5u); } uint16_t low_mv = _node_prefs ? _node_prefs->low_batt_mv : 0; if (low_mv > 0 && _batt_mv > 0 && _batt_mv < low_mv) { if (_display != NULL) { _display->startFrame(); _display->setTextSize(2); _display->setColor(DisplayDriver::LIGHT); _display->drawTextCentered(_display->width() / 2, 16, "Low Battery"); _display->drawTextCentered(_display->width() / 2, 36, "Shutting down"); _display->endFrame(); delay(2000); } shutdown(); } next_batt_chck = millis() + 8000; } } char UITask::checkDisplayOn(char c) { if (_display != NULL) { if (!_display->isOn()) { _display->turnOn(); #ifdef PIN_LED digitalWrite(PIN_LED, LOW); // ensure LED is off when waking display (userLedHandler takes over) #endif c = 0; } { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // 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(); return 0; } if (c == KEY_ENTER) return KEY_CONTEXT_MENU; 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::applyTxPower() { if (_node_prefs == NULL) return; radio_set_tx_power(_node_prefs->tx_power_dbm); } void UITask::applyGPSInterval() { if (_node_prefs == NULL) return; char buf[12]; sprintf(buf, "%u", _node_prefs->gps_interval); sensors.setSettingValue("gps_interval", buf); } void UITask::applyBrightness() { if (_display != NULL && _node_prefs != NULL) { _display->setBrightness(_node_prefs->display_brightness); } } void UITask::setBrightnessLevel(uint8_t level) { if (_node_prefs == NULL) return; if (level > 4) level = 4; _node_prefs->display_brightness = level; applyBrightness(); _next_refresh = 0; } void UITask::setBuzzerVolumeLevel(uint8_t level) { #ifdef PIN_BUZZER if (_node_prefs == NULL) return; if (level > 4) level = 4; _node_prefs->buzzer_volume = level; buzzer.setVolume(level); if (level > 0) buzzer.playForced("Vol:d=16,o=5,b=120:c"); _next_refresh = 0; #endif } void UITask::toggleBuzzer() { #ifdef PIN_BUZZER if (_node_prefs) _node_prefs->buzzer_auto = 0; // exit auto mode if (buzzer.isQuiet()) { buzzer.quiet(false); notify(UIEventType::ack); } else { buzzer.quiet(true); } if (_node_prefs) _node_prefs->buzzer_quiet = buzzer.isQuiet(); the_mesh.savePrefs(); showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800); _next_refresh = 0; #endif } int UITask::getBuzzerMode() { #ifdef PIN_BUZZER if (_node_prefs && _node_prefs->buzzer_auto) return 2; return buzzer.isQuiet() ? 1 : 0; #else return 1; #endif } void UITask::cycleBuzzerMode() { #ifdef PIN_BUZZER if (!_node_prefs) return; int mode = getBuzzerMode(); mode = (mode + 1) % 3; // ON(0) → OFF(1) → Auto(2) → ON _node_prefs->buzzer_auto = (mode == 2) ? 1 : 0; if (mode == 0) { buzzer.quiet(false); _node_prefs->buzzer_quiet = 0; notify(UIEventType::ack); } if (mode == 1) { buzzer.quiet(true); _node_prefs->buzzer_quiet = 1; } if (mode == 2) { buzzer.quiet(hasConnection()); } static const char* labels[] = { "Buzzer: ON", "Buzzer: OFF", "Buzzer: Auto" }; showAlert(labels[mode], 800); _next_refresh = 0; #endif }