#include "UITask.h" #include #include "../MyMesh.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" #include // --- Ручная настройка радио (страница RADIO) --- // Границы частоты по умолчанию — для SX126x (150..960 МГц). Для других модулей // можно переопределить в env варианта. #ifndef UI_RADIO_FREQ_MIN #define UI_RADIO_FREQ_MIN 150.0f #endif #ifndef UI_RADIO_FREQ_MAX #define UI_RADIO_FREQ_MAX 960.0f #endif #define UI_RADIO_FREQ_STEP_KHZ 25 // шаг перестройки частоты #define UI_RADIO_MENU_TIMEOUT_MILLIS 300000UL // 5 минут простоя — выйти из меню без сохранения #define NOTIFY_MAIN_ROWS 7 // строк в меню уведомлений // Строка меню: выбранная — инверсией, значение — справа вторичным цветом static void drawMenuRow(DisplayDriver& display, int y, bool sel, const char* label, const char* value) { if (sel) { display.setColor(UIColor::primary_txt); display.fillRect(0, y - 1, display.width(), 9); display.setColor(UIColor::window_bkg); display.drawTextLeftAlign(1, y, label); if (value[0]) display.drawTextRightAlign(display.width() - 2, y, value); } else { display.setColor(UIColor::primary_txt); display.drawTextLeftAlign(1, y, label); if (value[0]) { display.setColor(UIColor::secondary_txt); display.drawTextRightAlign(display.width() - 2, y, value); } } } // BW перебирается по списку допустимых значений, а не шагом static const float UI_RADIO_BW_STEPS[] = { 7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, 62.5f, 125.0f, 250.0f, 500.0f }; #define UI_RADIO_BW_STEPS_N (sizeof(UI_RADIO_BW_STEPS) / sizeof(UI_RADIO_BW_STEPS[0])) class SplashScreen : public UIScreen { UITask* _task; unsigned long dismiss_after; char _version_info[12]; public: SplashScreen(UITask* task) : _task(task) { // strip off dash and commit hash by changing dash to null terminator // e.g: v1.2.3-abcdef -> v1.2.3 const char *ver = FIRMWARE_VERSION; const char *dash = strchr(ver, '-'); int len = dash ? dash - ver : strlen(ver); if (len >= sizeof(_version_info)) len = sizeof(_version_info) - 1; memcpy(_version_info, ver, len); _version_info[len] = 0; dismiss_after = millis() + BOOT_SCREEN_MILLIS; } int render(DisplayDriver& display) override { // meshcore logo display.setColor(UIColor::corp_blue); int logoWidth = 128; display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); // meshcore website const char* website = "https://meshcore.io"; display.setColor(UIColor::primary_txt); display.setTextSize(1); uint16_t websiteWidth = display.getTextWidth(website); display.setCursor((display.width() - websiteWidth) / 2, 22); display.print(website); // version info display.setColor(UIColor::primary_txt); display.setTextSize(1); display.drawTextCentered(display.width()/2, 35, _version_info); display.setColor(UIColor::secondary_txt); display.setTextSize(1); #ifdef OLED_RU char filtered_date[sizeof(FIRMWARE_BUILD_DATE)]; display.translateUTF8ToBlocks(filtered_date, FIRMWARE_BUILD_DATE, sizeof(filtered_date)); display.drawTextCentered(display.width()/2, 42, filtered_date); #else display.drawTextCentered(display.width()/2, 42, FIRMWARE_BUILD_DATE); #endif 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 THEME, // выбор темы дисплея (только если драйвер поддерживает темы) 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]; // режим ручной настройки радио (страница RADIO) enum RadioRow { ROW_FQ, ROW_BW, ROW_SF, ROW_CR, ROW_TX, ROW_SAVE, ROW_COUNT }; bool _radio_edit; bool _radio_val_edit; uint8_t _radio_sel; unsigned long _radio_last_activity; // для автовыхода из меню по простою void radioEditEnter() { _task->clearAlert(); // висящий попап (например, от входящего сообщения) не должен перекрывать меню _radio_edit = true; _radio_val_edit = false; _radio_sel = ROW_FQ; _radio_last_activity = millis(); } void radioEditLeave(bool save) { _radio_edit = false; _radio_val_edit = false; if (save) { the_mesh.savePrefs(); _task->showAlert("Saved", 1000); } _task->showPendingMessages(); // сообщения, пришедшие во время настройки } void radioRowStep(int8_t dir) { _radio_sel = (_radio_sel + ROW_COUNT + dir) % ROW_COUNT; } void radioValueStep(int8_t dir); void radioRowValue(uint8_t row, char* buf, size_t len); const char* radioRowName(uint8_t row); void renderRadioEditor(DisplayDriver& display); // выбор темы дисплея (страница THEME) bool _theme_edit; uint8_t _theme_sel; unsigned long _theme_last_activity; void themeEditEnter() { DisplayDriver* d = _task->getDisplay(); if (d == NULL || d->getThemeCount() == 0) return; _task->clearAlert(); _theme_edit = true; _theme_sel = d->getTheme(); // начинаем с активной темы _theme_last_activity = millis(); } void themeStore() { // запомнить текущую (предпросмотренную) тему в prefs DisplayDriver* d = _task->getDisplay(); if (d == NULL) return; const char* name = d->getThemeName(d->getTheme()); strncpy(_node_prefs->ui_theme, name, sizeof(_node_prefs->ui_theme) - 1); _node_prefs->ui_theme[sizeof(_node_prefs->ui_theme) - 1] = 0; the_mesh.savePrefs(); } void themeEditLeave(bool save) { _theme_edit = false; if (save) { themeStore(); _task->showAlert("Saved", 1000); } _task->showPendingMessages(); } void themeRowStep(int8_t dir) { DisplayDriver* d = _task->getDisplay(); if (d == NULL) return; uint8_t rows = d->getThemeCount() + 1; // темы + "Save & exit" _theme_sel = (_theme_sel + rows + dir) % rows; if (_theme_sel < d->getThemeCount()) d->setTheme(_theme_sel); // живой предпросмотр } void renderThemePage(DisplayDriver& display); void renderThemeEditor(DisplayDriver& display); uint8_t pageStep(int8_t dir); // меню уведомлений (первый экран, длинное нажатие) enum NotifyScreen { NOTIFY_OFF, NOTIFY_MAIN, NOTIFY_CHANNELS, NOTIFY_QUIET }; uint8_t _notify_screen; uint8_t _notify_sel; bool _notify_val_edit; // правка часа в подменю тихих часов unsigned long _notify_last_activity; void notifyEnter() { _task->clearAlert(); _notify_screen = NOTIFY_MAIN; _notify_sel = 0; _notify_val_edit = false; _notify_last_activity = millis(); } void notifyLeave(bool save) { _notify_screen = NOTIFY_OFF; _notify_val_edit = false; if (save) { the_mesh.savePrefs(); _task->showAlert("Saved", 1000); } _task->showPendingMessages(); } uint8_t alertsMode() const { // 0 = звук+вибро, 1 = только звук, 2 = только вибро, 3 = выкл bool snd = _node_prefs->buzzer_quiet == 0, vib = _node_prefs->vibe_quiet == 0; return (uint8_t)(snd ? (vib ? 0 : 1) : (vib ? 2 : 3)); } void alertsCycle() { uint8_t m = (uint8_t)((alertsMode() + 1) & 3); // 0 both, 1 sound, 2 vibe, 3 off _node_prefs->buzzer_quiet = (m == 2 || m == 3) ? 1 : 0; _node_prefs->vibe_quiet = (m == 1 || m == 3) ? 1 : 0; } uint8_t channelCount(); uint8_t channelIdxAt(uint8_t pos); uint8_t notifyRowCount(); void notifyStep(int8_t dir); void notifyActivate(); void notifyValueStep(int8_t dir); void renderNotifyMenu(DisplayDriver& display); void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { // Convert millivolts to percentage #ifndef BATT_MIN_MILLIVOLTS #define BATT_MIN_MILLIVOLTS 3000 #endif #ifndef BATT_MAX_MILLIVOLTS #define BATT_MAX_MILLIVOLTS 4200 #endif const int minMilliVolts = BATT_MIN_MILLIVOLTS; const int maxMilliVolts = BATT_MAX_MILLIVOLTS; int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts); if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0% if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100% // battery icon int iconWidth = 24; int iconHeight = 10; int iconX = display.width() - iconWidth - 5; // Position the icon near the top-right corner int iconY = 0; display.setColor(UIColor::title_txt); // battery outline display.drawRect(iconX, iconY, iconWidth, iconHeight); // battery "cap" display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2); // fill the battery based on the percentage int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100; display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4); // show muted icon if buzzer is muted #ifdef PIN_BUZZER if (_task->isBuzzerQuiet()) { display.setColor(UIColor::warning_txt); display.drawXbm(iconX - 9, iconY + 1, muted_icon, 8, 8); } #endif } 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), _radio_edit(false), _radio_val_edit(false), _radio_sel(0), _radio_last_activity(0), _theme_edit(false), _theme_sel(0), _theme_last_activity(0), _notify_screen(NOTIFY_OFF), _notify_sel(0), _notify_val_edit(false), _notify_last_activity(0), sensors_lpp(200) { } // в меню настроек (радио, темы, уведомления) входящие сообщения не всплывают поверх экрана bool suppressAlerts() override { return _radio_edit || _theme_edit || _notify_screen != NOTIFY_OFF; } void poll() override { if (_radio_edit) { if (millis() - _radio_last_activity >= UI_RADIO_MENU_TIMEOUT_MILLIS) { radioEditLeave(false); // 5 минут без действий: выходим без сохранения _task->blankDisplay(); // и гасим экран } else { _task->keepDisplayOn(); // пока работаем — экран не гасим по таймауту } } if (_theme_edit) { if (millis() - _theme_last_activity >= UI_RADIO_MENU_TIMEOUT_MILLIS) { themeEditLeave(false); _task->blankDisplay(); } else { _task->keepDisplayOn(); } } if (_notify_screen != NOTIFY_OFF) { if (millis() - _notify_last_activity >= UI_RADIO_MENU_TIMEOUT_MILLIS) { notifyLeave(false); _task->blankDisplay(); } else { _task->keepDisplayOn(); } } if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released _task->shutdown(); } } int render(DisplayDriver& display) override { if (_radio_edit) { // режим настройки радио: без статус-бара (имя ноды / заряд) и точек страниц — // всё место отдано параметрам renderRadioEditor(display); return 5000; } if (_theme_edit) { renderThemeEditor(display); return 5000; } if (_notify_screen != NOTIFY_OFF) { renderNotifyMenu(display); return 5000; } display.setColor(UIColor::title_bkg); display.fillRect(0, 0, display.width(), 12); char tmp[80]; // node name display.setTextSize(1); display.setColor(UIColor::title_txt); char filtered_name[sizeof(_node_prefs->node_name)]; display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); display.setCursor(0, 2); display.print(filtered_name); // battery voltage renderBatteryIndicator(display, _task->getBattMilliVolts()); // curr page indicator: активная точка — акцентным цветом, остальные — вторичным. // Раньше неактивные точки красились в title_bkg, и на тёмных палитрах // (тёмная полоса на тёмном фоне) индикатор становился неразличим. int y = 14; int x = display.width() / 2 - 5 * (HomePage::Count-1); for (uint8_t i = 0; i < HomePage::Count; i++, x += 10) { if (i == _page) { display.setColor(UIColor::warning_txt); display.fillRect(x-1, y-1, 4, 4); } else { display.setColor(UIColor::secondary_txt); display.fillRect(x, y, 2, 2); } } if (_page == HomePage::FIRST) { display.setColor(UIColor::primary_txt); display.setTextSize(2); sprintf(tmp, "MSG: %d", _task->getMsgCount()); display.drawTextCentered(display.width() / 2, 22, 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(UIColor::warning_txt); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 43, "< Connected >"); } else if (the_mesh.getBLEPin() != 0) { // BT pin display.setColor(UIColor::warning_txt); display.setTextSize(2); sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); display.drawTextCentered(display.width() / 2, 43, tmp); } } else if (_page == HomePage::RECENT) { the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE); display.setColor(UIColor::primary_txt); 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(UIColor::primary_txt); 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, 29); sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); display.print(tmp); // tx power, noise floor display.setCursor(0, 38); sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm); display.print(tmp); display.setCursor(0, 47); sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor()); display.print(tmp); display.setColor(UIColor::secondary_txt); display.drawTextCentered(display.width() / 2, 56, "setup: " PRESS_LABEL); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 18, _task->isBluetoothEnabled() ? bluetooth_on : bluetooth_off, 32, 32); display.setColor(UIColor::secondary_txt); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL); } else if (_page == HomePage::ADVERT) { display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32); display.setColor(UIColor::secondary_txt); 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.setColor(UIColor::primary_txt); display.drawTextLeftAlign(0, y, buf); if (nmea == NULL) { y = y + 12; display.setColor(UIColor::secondary_txt); display.drawTextLeftAlign(0, y, "Can't access GPS"); } else { display.setColor(UIColor::primary_txt); strcpy(buf, nmea->isValid()?"fix":"no fix"); display.drawTextRightAlign(display.width()-1, y, buf); y = y + 12; display.setColor(UIColor::secondary_txt); display.drawTextLeftAlign(0, y, "sat"); display.setColor(UIColor::primary_txt); sprintf(buf, "%d", nmea->satellitesCount()); display.drawTextRightAlign(display.width()-1, y, buf); y = y + 12; display.setColor(UIColor::secondary_txt); display.drawTextLeftAlign(0, y, "pos"); display.setColor(UIColor::primary_txt); sprintf(buf, "%.4f %.4f", nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.); display.drawTextRightAlign(display.width()-1, y, buf); y = y + 12; display.setColor(UIColor::secondary_txt); display.drawTextLeftAlign(0, y, "alt"); display.setColor(UIColor::primary_txt); 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(); 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.setColor(UIColor::secondary_txt); display.print(name); display.setColor(UIColor::primary_txt); 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::THEME) { renderThemePage(display); } else if (_page == HomePage::SHUTDOWN) { display.setColor(UIColor::corp_blue); display.setTextSize(1); if (_shutdown_init) { display.setColor(UIColor::warning_txt); display.drawTextCentered(display.width() / 2, 34, "hibernating..."); } else { display.setColor(UIColor::secondary_txt); display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32); display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL); } } return 5000; // next render after 5000 ms } bool handleInput(char c) override { if (_radio_edit) { // режим настройки радио: страницы не листаются _radio_last_activity = millis(); // любое нажатие продлевает жизнь меню if (c == KEY_NEXT || c == KEY_RIGHT) { if (_radio_val_edit) radioValueStep(+1); else radioRowStep(+1); return true; } if (c == KEY_PREV || c == KEY_LEFT) { if (_radio_val_edit) radioValueStep(-1); else radioRowStep(-1); return true; } if (c == KEY_ENTER) { if (_radio_val_edit) { _radio_val_edit = false; // назад к списку } else if (_radio_sel == ROW_SAVE) { radioEditLeave(true); // сохранить в память и выйти } else { _radio_val_edit = true; // править значение } return true; } return true; // остальные события в этом режиме игнорируем } if (_theme_edit) { // меню выбора темы _theme_last_activity = millis(); if (c == KEY_NEXT || c == KEY_RIGHT) { themeRowStep(+1); return true; } if (c == KEY_PREV || c == KEY_LEFT) { themeRowStep(-1); return true; } if (c == KEY_ENTER) { DisplayDriver* d = _task->getDisplay(); if (d != NULL) { if (_theme_sel >= d->getThemeCount()) { themeEditLeave(true); // строка "Save & exit" — сохранить и выйти } else { themeStore(); // длинное нажатие на теме — запомнить её сейчас char msg[28]; snprintf(msg, sizeof(msg), "theme %s OK", d->getThemeName(d->getTheme())); _task->showAlert(msg, 1200); } } return true; } return true; } if (_notify_screen != NOTIFY_OFF) { // меню уведомлений _notify_last_activity = millis(); if (c == KEY_NEXT || c == KEY_RIGHT) { if (_notify_val_edit) notifyValueStep(+1); else notifyStep(+1); return true; } if (c == KEY_PREV || c == KEY_LEFT) { if (_notify_val_edit) notifyValueStep(-1); else notifyStep(-1); return true; } if (c == KEY_ENTER) { notifyActivate(); return true; } return true; } if (c == KEY_LEFT || c == KEY_PREV) { _page = pageStep(-1); return true; } if (c == KEY_NEXT || c == KEY_RIGHT) { _page = pageStep(+1); if (_page == HomePage::RECENT) { _task->showAlert("Recent adverts", 800); } return true; } if (c == KEY_ENTER && _page == HomePage::RADIO) { radioEditEnter(); // предупреждение: остальные узлы сети должны иметь те же параметры _task->showAlert("Nodes must match", 1500); return true; } if (c == KEY_ENTER && _page == HomePage::FIRST) { notifyEnter(); // настройки входящих сообщений и уведомлений return true; } if (c == KEY_ENTER && _page == HomePage::THEME) { themeEditEnter(); return true; } if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) { if (_task->isBluetoothEnabled()) { // toggle Bluetooth on/off _task->disableBluetooth(); } else { _task->enableBluetooth(); } 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 HomeScreen::radioValueStep(int8_t dir) { switch (_radio_sel) { case ROW_FQ: { int32_t khz = (int32_t)(_node_prefs->freq * 1000.0f + 0.5f) + dir * UI_RADIO_FREQ_STEP_KHZ; int32_t lo = (int32_t)(UI_RADIO_FREQ_MIN * 1000.0f), hi = (int32_t)(UI_RADIO_FREQ_MAX * 1000.0f); if (khz < lo) khz = lo; if (khz > hi) khz = hi; if (!the_mesh.setRadioParams((float)khz / 1000.0f, _node_prefs->bw, _node_prefs->sf, _node_prefs->cr)) { _task->showAlert("Freq not allowed", 1200); } break; } case ROW_BW: { // ищем ближайшее допустимое значение; если текущее не из списка — // первый шаг приводит к ближайшему, дальше идём по списку int idx = 0; for (int i = 1; i < (int)UI_RADIO_BW_STEPS_N; i++) { if (fabsf(UI_RADIO_BW_STEPS[i] - _node_prefs->bw) < fabsf(UI_RADIO_BW_STEPS[idx] - _node_prefs->bw)) idx = i; } int next = idx; if (fabsf(UI_RADIO_BW_STEPS[idx] - _node_prefs->bw) < 0.01f) { next = idx + dir; if (next < 0) next = 0; if (next >= (int)UI_RADIO_BW_STEPS_N) next = (int)UI_RADIO_BW_STEPS_N - 1; } the_mesh.setRadioParams(_node_prefs->freq, UI_RADIO_BW_STEPS[next], _node_prefs->sf, _node_prefs->cr); break; } case ROW_SF: { int v = (int)_node_prefs->sf + dir; if (v < 5) v = 5; if (v > 12) v = 12; the_mesh.setRadioParams(_node_prefs->freq, _node_prefs->bw, (uint8_t)v, _node_prefs->cr); break; } case ROW_CR: { int v = (int)_node_prefs->cr + dir; if (v < 5) v = 5; if (v > 8) v = 8; the_mesh.setRadioParams(_node_prefs->freq, _node_prefs->bw, _node_prefs->sf, (uint8_t)v); break; } case ROW_TX: { the_mesh.setTxPowerDbm((int8_t)((int)_node_prefs->tx_power_dbm + dir)); break; } } } void HomeScreen::radioRowValue(uint8_t row, char* buf, size_t len) { switch (row) { case ROW_FQ: snprintf(buf, len, "%.3f MHz", _node_prefs->freq); break; case ROW_BW: snprintf(buf, len, "%.2f kHz", _node_prefs->bw); break; case ROW_SF: snprintf(buf, len, "%u", (unsigned)_node_prefs->sf); break; case ROW_CR: snprintf(buf, len, "%u", (unsigned)_node_prefs->cr); break; case ROW_TX: snprintf(buf, len, "%d dBm", (int)_node_prefs->tx_power_dbm); break; default: buf[0] = 0; break; } } const char* HomeScreen::radioRowName(uint8_t row) { switch (row) { case ROW_FQ: return "FQ"; case ROW_BW: return "BW"; case ROW_SF: return "SF"; case ROW_CR: return "CR"; case ROW_TX: return "TX"; default: return "Save & exit"; } } void HomeScreen::renderRadioEditor(DisplayDriver& display) { char buf[24]; char head[32]; if (_radio_val_edit) { // фаза правки значения switch (_radio_sel) { case ROW_FQ: snprintf(head, sizeof(head), "FQ %.0f-%.0f MHz", UI_RADIO_FREQ_MIN, UI_RADIO_FREQ_MAX); break; case ROW_BW: strcpy(head, "BW 7.8-500 kHz"); break; case ROW_SF: strcpy(head, "SF 5-12"); break; case ROW_CR: strcpy(head, "CR 5-8"); break; default: snprintf(head, sizeof(head), "TX -9-%d dBm", MAX_LORA_TX_POWER); break; } display.setColor(UIColor::secondary_txt); display.setTextSize(1); display.drawTextLeftAlign(0, 2, head); radioRowValue(_radio_sel, buf, sizeof(buf)); display.setColor(UIColor::warning_txt); display.setTextSize(2); display.drawTextCentered(display.width() / 2, 20, buf); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 64 - 10, "click+ dbl- hold=back"); return; } // фаза выбора: выбранная строка инвертирована display.setTextSize(1); int y = 2; for (uint8_t i = 0; i < ROW_COUNT; i++, y += 10) { radioRowValue(i, buf, sizeof(buf)); if (i == _radio_sel) { display.setColor(UIColor::primary_txt); display.fillRect(0, y - 1, display.width(), 10); display.setColor(UIColor::window_bkg); display.drawTextLeftAlign(1, y, radioRowName(i)); if (buf[0]) display.drawTextRightAlign(display.width() - 2, y, buf); } else { display.setColor(UIColor::primary_txt); display.drawTextLeftAlign(1, y, radioRowName(i)); if (buf[0]) { display.setColor(UIColor::secondary_txt); display.drawTextRightAlign(display.width() - 2, y, buf); } } } } uint8_t HomeScreen::pageStep(int8_t dir) { uint8_t p = _page; for (uint8_t i = 0; i < HomePage::Count; i++) { p = (p + HomePage::Count + dir) % HomePage::Count; // страница тем есть только у драйверов, которые поддерживают палитры if (p != HomePage::THEME || _task->displayHasThemes()) return p; } return _page; } void HomeScreen::renderThemePage(DisplayDriver& display) { char tmp[32]; uint8_t n = display.getThemeCount(); display.setTextSize(1); display.setColor(UIColor::primary_txt); snprintf(tmp, sizeof(tmp), "Theme: %s", n > 0 ? display.getThemeName(display.getTheme()) : "-"); display.setCursor(0, 22); display.print(tmp); display.setColor(UIColor::secondary_txt); display.drawTextCentered(display.width() / 2, 56, "setup: " PRESS_LABEL); } void HomeScreen::renderThemeEditor(DisplayDriver& display) { uint8_t n = display.getThemeCount(); uint8_t rows = n + 1; // темы + "Save & exit" const uint8_t VISIBLE = 6; // 6 строк по 10 px влезают в 64 uint8_t first = _theme_sel >= VISIBLE ? _theme_sel - VISIBLE + 1 : 0; display.setTextSize(1); int y = 2; for (uint8_t r = first; r < rows && y <= 52; r++, y += 10) { const char* label = (r < n) ? display.getThemeName(r) : "Save & exit"; if (r == _theme_sel) { display.setColor(UIColor::primary_txt); // выбранная строка — инверсия display.fillRect(0, y - 1, display.width(), 10); display.setColor(UIColor::window_bkg); } else { display.setColor(UIColor::primary_txt); } display.drawTextLeftAlign(1, y, label); if (r < n) { // образцы цветов темы: полоса заголовка, заливка попапа, акцент int sx = display.width() - 32; display.setColor(display.getThemeColor(r, DisplayDriver::THEME_COLOR_TITLE)); display.fillRect(sx, y, 12, 8); display.setColor(display.getThemeColor(r, DisplayDriver::THEME_COLOR_POPUP)); display.fillRect(sx + 13, y, 8, 8); display.setColor(display.getThemeColor(r, DisplayDriver::THEME_COLOR_ACCENT)); display.fillRect(sx + 22, y, 10, 8); } } } uint8_t HomeScreen::channelCount() { uint8_t n = 0; ChannelDetails d; for (uint8_t i = 0; i < NOTIFY_MAX_CHANNELS; i++) { if (the_mesh.getChannel(i, d) && d.name[0] != 0) n++; } return n; } uint8_t HomeScreen::channelIdxAt(uint8_t pos) { uint8_t n = 0; ChannelDetails d; for (uint8_t i = 0; i < NOTIFY_MAX_CHANNELS; i++) { if (the_mesh.getChannel(i, d) && d.name[0] != 0) { if (n == pos) return i; n++; } } return 0xFF; } uint8_t HomeScreen::notifyRowCount() { switch (_notify_screen) { case NOTIFY_QUIET: return 5; // Quiet hours / From / To / Timezone / Back case NOTIFY_CHANNELS: return (uint8_t)(channelCount() + 1); // каналы + Back default: return NOTIFY_MAIN_ROWS; } } void HomeScreen::notifyStep(int8_t dir) { uint8_t rows = notifyRowCount(); if (rows == 0) return; _notify_sel = (uint8_t)((_notify_sel + rows + dir) % rows); } void HomeScreen::notifyValueStep(int8_t dir) { // правка часа тишины и часового пояса if (_notify_screen != NOTIFY_QUIET) return; if (_notify_sel == 1 || _notify_sel == 2) { int v = (int)(_notify_sel == 1 ? _node_prefs->quiet_from : _node_prefs->quiet_to) + dir; v %= 24; if (v < 0) v += 24; if (_notify_sel == 1) _node_prefs->quiet_from = (uint8_t)v; else _node_prefs->quiet_to = (uint8_t)v; } else if (_notify_sel == 3) { // timezone, -12..+14 int v = (int)_node_prefs->tz_offset + dir; if (v < -12) v = -12; if (v > 14) v = 14; _node_prefs->tz_offset = (int8_t)v; } } void HomeScreen::notifyActivate() { // длинное нажатие if (_notify_val_edit) { _notify_val_edit = false; // закончили правку часа return; } if (_notify_screen == NOTIFY_MAIN) { switch (_notify_sel) { case 0: // Unread — открыть непрочитанные notifyLeave(false); _task->showMessages(); return; case 1: _node_prefs->msg_popup = _node_prefs->msg_popup ? 0 : 1; return; case 2: _node_prefs->msg_wake_display = _node_prefs->msg_wake_display ? 0 : 1; return; case 3: alertsCycle(); return; case 4: _notify_screen = NOTIFY_CHANNELS; _notify_sel = 0; return; case 5: _notify_screen = NOTIFY_QUIET; _notify_sel = 0; return; default: notifyLeave(true); return; // Save & exit } } if (_notify_screen == NOTIFY_QUIET) { switch (_notify_sel) { case 0: _node_prefs->quiet_en = _node_prefs->quiet_en ? 0 : 1; return; case 1: case 2: case 3: _notify_val_edit = true; return; // править час / часовой пояс default: _notify_screen = NOTIFY_MAIN; _notify_sel = 5; return; // Back } } if (_notify_screen == NOTIFY_CHANNELS) { if (_notify_sel >= channelCount()) { // Back _notify_screen = NOTIFY_MAIN; _notify_sel = 4; return; } uint8_t idx = channelIdxAt(_notify_sel); if (idx != 0xFF) { uint8_t m = (uint8_t)((_node_prefs->getChannelMode(idx) + 1) % 3); _node_prefs->setChannelMode(idx, m); // POPUP -> SILENT -> OFF } } } void HomeScreen::renderNotifyMenu(DisplayDriver& display) { char buf[24]; char val[16]; display.setTextSize(1); if (_notify_screen == NOTIFY_QUIET) { static const char* names[5] = { "Quiet hours", "From", "To", "Timezone", "Back" }; int y = 2; for (uint8_t r = 0; r < 5; r++, y += 10) { val[0] = 0; if (r == 0) strcpy(val, _node_prefs->quiet_en ? "on" : "off"); else if (r == 1) snprintf(val, sizeof(val), "%02d:00", _node_prefs->quiet_from); else if (r == 2) snprintf(val, sizeof(val), "%02d:00", _node_prefs->quiet_to); else if (r == 3) snprintf(val, sizeof(val), "%+03d", (int)_node_prefs->tz_offset); drawMenuRow(display, y, r == _notify_sel, names[r], val); } if (_notify_val_edit) { display.setColor(UIColor::warning_txt); display.drawTextCentered(display.width() / 2, 64 - 10, "click+ dbl- hold=back"); } return; } if (_notify_screen == NOTIFY_CHANNELS) { uint8_t n = channelCount(); uint8_t rows = (uint8_t)(n + 1); const uint8_t VISIBLE = 6; uint8_t first = _notify_sel >= VISIBLE ? (uint8_t)(_notify_sel - VISIBLE + 1) : 0; static const char* mode_names[3] = { "popup", "silent", "off" }; int y = 2; for (uint8_t r = first; r < rows && y <= 52; r++, y += 10) { if (r < n) { uint8_t idx = channelIdxAt(r); ChannelDetails cd; if (the_mesh.getChannel(idx, cd)) { strncpy(buf, cd.name, sizeof(buf) - 1); buf[sizeof(buf) - 1] = 0; } else { strcpy(buf, "?"); } strcpy(val, mode_names[_node_prefs->getChannelMode(idx) % 3]); } else { strcpy(buf, "Back"); val[0] = 0; } drawMenuRow(display, y, r == _notify_sel, buf, val); } return; } // NOTIFY_MAIN static const char* labels[NOTIFY_MAIN_ROWS] = { "Unread", "Popups", "Wake screen", "Alerts", "Channels", "Quiet hours", "Save & exit" }; static const char* alert_names[4] = { "sound+vibe", "sound", "vibe", "off" }; int y = 1; for (uint8_t r = 0; r < NOTIFY_MAIN_ROWS; r++, y += 9) { val[0] = 0; switch (r) { case 0: snprintf(val, sizeof(val), "%d", _task->getMsgCount()); break; case 1: strcpy(val, _node_prefs->msg_popup ? "on" : "off"); break; case 2: strcpy(val, _node_prefs->msg_wake_display ? "on" : "off"); break; case 3: strcpy(val, alert_names[alertsMode()]); break; case 4: snprintf(val, sizeof(val), "%d >", channelCount()); break; case 5: if (_node_prefs->quiet_en) snprintf(val, sizeof(val), "%02d-%02d", _node_prefs->quiet_from, _node_prefs->quiet_to); else strcpy(val, "off"); break; default: break; } drawMenuRow(display, y, r == _notify_sel, labels[r], val); } } class MsgPreviewScreen : public UIScreen { UITask* _task; mesh::RTCClock* _rtc; struct MsgEntry { uint32_t timestamp; char origin[62]; char msg[78]; }; #define MAX_UNREAD_MSGS 32 int num_unread; int head = MAX_UNREAD_MSGS - 1; // index of latest unread message MsgEntry unread[MAX_UNREAD_MSGS]; public: MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc) { num_unread = 0; } void addPreview(uint8_t path_len, const char* from_name, const char* msg) { head = (head + 1) % MAX_UNREAD_MSGS; if (num_unread < MAX_UNREAD_MSGS) num_unread++; auto p = &unread[head]; p->timestamp = _rtc->getCurrentTime(); if (path_len == 0xFF) { sprintf(p->origin, "(D) %s:", from_name); } else { sprintf(p->origin, "(%d) %s:", (uint32_t) path_len, from_name); } StrHelper::strncpy(p->msg, msg, sizeof(p->msg)); } int render(DisplayDriver& display) override { char tmp[16]; display.setCursor(0, 0); display.setTextSize(1); display.setColor(UIColor::corp_blue); sprintf(tmp, "Unread: %d", num_unread); display.print(tmp); auto p = &unread[head]; int secs = _rtc->getCurrentTime() - p->timestamp; if (secs < 60) { sprintf(tmp, "%ds", secs); } else if (secs < 60*60) { sprintf(tmp, "%dm", secs / 60); } else { sprintf(tmp, "%dh", secs / (60*60)); } display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0); display.print(tmp); display.drawRect(0, 11, display.width(), 1); // horiz line display.setCursor(0, 14); display.setColor(UIColor::secondary_txt); char filtered_origin[sizeof(p->origin)]; display.translateUTF8ToBlocks(filtered_origin, p->origin, sizeof(filtered_origin)); display.print(filtered_origin); display.setCursor(0, 25); display.setColor(UIColor::primary_txt); char filtered_msg[sizeof(p->msg)]; display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg)); display.printWordWrap(filtered_msg, display.width()); #if AUTO_OFF_MILLIS==0 // probably e-ink return 10000; // 10 s #else return 1000; // next render after 1000 ms #endif } bool handleInput(char c) override { if (c == KEY_NEXT || c == KEY_RIGHT) { head = (head + MAX_UNREAD_MSGS - 1) % MAX_UNREAD_MSGS; num_unread--; if (num_unread == 0) { _task->gotoHomeScreen(); } return true; } if (c == KEY_ENTER) { num_unread = 0; // clear unread queue _task->gotoHomeScreen(); return true; } return false; } }; void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) { _display = display; _sensors = sensors; _auto_off = millis() + AUTO_OFF_MILLIS; #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) { // применить сохранённую тему дисплея (если драйвер их поддерживает) if (_display->getThemeCount() > 0) { uint8_t idx = _display->findTheme(_node_prefs->ui_theme); if (idx != 0xFF) _display->setTheme(idx); } _display->turnOn(); } #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; _msg_pending = false; splash = new SplashScreen(this); home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); msg_preview = new MsgPreviewScreen(this, &rtc_clock); setCurrScreen(splash); } void UITask::showAlert(const char* text, int duration_millis) { strcpy(_alert, text); _alert_expiry = millis() + duration_millis; } void UITask::keepDisplayOn() { _auto_off = millis() + AUTO_OFF_MILLIS; // экран не гаснет, пока идёт работа с меню } void UITask::blankDisplay() { if (_display != NULL && _display->isOn()) { _display->turnOff(); } } void UITask::clearAlert() { _alert_expiry = 0; } void UITask::showPendingMessages() { if (_msg_pending) { _msg_pending = false; if (_msgcount > 0) { setCurrScreen(msg_preview); } } } 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::queueMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { _msgcount = msgcount; ((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text); } void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { queueMsg(path_len, from_name, text, msgcount); if (curr != NULL && curr->suppressAlerts()) { // Экран попросил не перекрывать его (меню настройки): сообщение уже // в очереди непрочитанных, покажем его после выхода из меню. _msg_pending = true; _next_refresh = 100; return; } setCurrScreen(msg_preview); if (_display != NULL) { // зажигать экран по входящему можно запретить (в меню уведомлений) if (!_display->isOn() && !hasConnection() && _node_prefs->msg_wake_display) { _display->turnOn(); } if (_display->isOn()) { _auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer _next_refresh = 100; // trigger refresh } } } void UITask::newMsgSilent(uint8_t path_len, const char* from_name, const char* text, int msgcount) { // тихий режим (часы тишины или канал SILENT): только в непрочитанные, // экран не трогаем и звук не подаём queueMsg(path_len, from_name, text, msgcount); _msg_pending = true; _next_refresh = 100; // чтобы обновился счётчик на первом экране } void UITask::showMessages() { _msg_pending = false; setCurrScreen(msg_preview); } 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 { // Power off board including radio, display, GPS and components _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(UI_HAS_ROTARY_INPUT) RotaryInputEvent rotaryEv = rotary_input.poll(); if (c == 0 && _display != NULL && _display->isOn()) { if (rotaryEv == RotaryInputEvent::Next) { c = KEY_NEXT; } else if (rotaryEv == RotaryInputEvent::Prev) { c = KEY_PREV; } } #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()) { if (millis() >= _next_refresh && curr) { _display->startFrame(); int delay_millis = curr->render(*_display); if (millis() < _alert_expiry) { // render alert popup _display->setTextSize(1); int y = _display->height() / 3; int p = _display->height() / 32; _display->setColor(UIColor::popup_bkg); _display->fillRect(p, y, _display->width() - p*2, y); _display->setColor(UIColor::popup_txt); // 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 #ifdef KEEP_DISPLAY_ON_USB // Opt-in: refresh the auto-off deadline while externally powered, so the // timer counts from the moment external power is removed. Off by default // because OLED panels burn in quickly; only enable for LCD targets or // where the display is replaceable. if (board.isExternalPowered()) { _auto_off = millis() + AUTO_OFF_MILLIS; } #endif if (millis() > _auto_off) { _display->turnOff(); } #endif } #ifdef PIN_VIBRATION vibration.loop(); #endif #ifdef AUTO_SHUTDOWN_MILLIVOLTS if (millis() > next_batt_chck) { uint16_t milliVolts = getBattMilliVolts(); if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) { if(!board.isExternalPowered()) { if (_display != NULL) { _display->startFrame(); _display->setTextSize(2); _display->setColor(UIColor::warning_txt); _display->drawTextCentered(_display->width() / 2, 20, "Low Battery."); _display->drawTextCentered(_display->width() / 2, 40, "Shutting Down!"); _display->endFrame(); if (_display->isEink() == false) { delay(3000); } } shutdown(); } } 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 }