#include "UITask.h" #include "target.h" #include #if defined(ESP32) #include #endif #include #include #ifndef USER_BTN_PRESSED #define USER_BTN_PRESSED LOW #endif #define AUTO_OFF_MILLIS 20000 // 20 seconds #define WIZARD_AUTO_OFF_MILLIS 60000 // в мастере настройки экран живёт дольше #define BOOT_SCREEN_MILLIS 4000 // 4 seconds #define POWEROFF_DELAY 3000 #define TOAST_MILLIS 3000 #define CHAR_W 6 // ширина символа при setTextSize(1) // позиции курсора на экране частоты: МГц, три разряда кГц и пункт сохранения #define FREQ_MHZ_POS 0 #define FREQ_SAVE_POS 4 #define FREQ_POSITIONS 5 // Пункты мастера. Подписи латиницей: шрифты драйверов не содержат кириллицы. enum { MENU_COORDS = 0, MENU_FREQ, MENU_BW, MENU_CR, MENU_SF, MENU_TX, MENU_REPEAT, MENU_PATHHASH, MENU_ADVERT, MENU_FLOODADV, MENU_FLOODMAX, MENU_EXIT, MENU_COUNT }; static const char* const MENU_NAMES[MENU_COUNT] = { "Coordinates", "Frequency", "Bandwidth", "CR", "SF", "TX power", "Repeat", "Path hash", "Advert int", "Flood advert", "Flood max", "Save & exit" }; static const char* const PATH_HASH_NAMES[] = { "0 bytes", "1 byte", "2 bytes" }; // позиции цифр в строке вида "xx.xxxx" static const uint8_t COORD_POS[6] = { 0, 1, 3, 4, 5, 6 }; // 'meshcore', 128x13px static const uint8_t meshcore_logo [] PROGMEM = { 0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x3c, 0x03, 0xe3, 0xff, 0xc7, 0xff, 0x8e, 0x03, 0x8f, 0xfe, 0x3f, 0xfe, 0x1f, 0xff, 0x1f, 0xfe, 0x3e, 0x03, 0xc3, 0xff, 0x8f, 0xff, 0x0e, 0x07, 0x8f, 0xfe, 0x7f, 0xfe, 0x1f, 0xff, 0x1f, 0xfc, 0x3e, 0x07, 0xc7, 0x80, 0x0e, 0x00, 0x0e, 0x07, 0x9e, 0x00, 0x78, 0x0e, 0x3c, 0x0f, 0x1c, 0x00, 0x3e, 0x0f, 0xc7, 0x80, 0x1e, 0x00, 0x0e, 0x07, 0x1e, 0x00, 0x70, 0x0e, 0x38, 0x0f, 0x3c, 0x00, 0x7f, 0x0f, 0xc7, 0xfe, 0x1f, 0xfc, 0x1f, 0xff, 0x1c, 0x00, 0x70, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, 0x7f, 0x1f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, 0x7f, 0x3f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x1e, 0x3f, 0xfe, 0x3f, 0xf0, 0x77, 0x3b, 0x87, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xfc, 0x38, 0x00, 0x77, 0xfb, 0x8f, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xf8, 0x38, 0x00, 0x73, 0xf3, 0x8f, 0xff, 0x0f, 0xff, 0x1c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x78, 0x7f, 0xf8, 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfe, 0x3c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x3c, 0x7f, 0xf8, 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8, }; static void stepClamped(int& v, int dir, int lo, int hi, int step) { v += dir * step; if (v < lo) v = lo; if (v > hi) v = hi; } // строка-пункт: при выборе выделяется инверсией static void renderRow(DisplayDriver* d, int y, const char* label, bool active) { d->setTextSize(1); if (active) { d->setColor(UIColor::primary_txt); d->fillRect(0, y - 1, d->width(), 10); d->setColor(UIColor::window_bkg); } else { d->setColor(UIColor::primary_txt); } d->setCursor(1, y); d->print(label); d->setColor(UIColor::primary_txt); } void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version, CommonCLI* cli) { _prevBtnState = HIGH; _auto_off = millis() + AUTO_OFF_MILLIS; _started_at = millis(); _node_prefs = node_prefs; _cli = cli; _toast[0] = 0; _display->turnOn(); #if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) user_btn.begin(); #endif // strip off dash and commit hash by changing dash to null terminator // e.g: v1.2.3-abcdef -> v1.2.3 char *version = strdup(firmware_version); char *dash = strchr(version, '-'); if(dash){ *dash = 0; } // v1.2.3 (1 Jan 2025) snprintf(_version_info, sizeof(_version_info), "%s (%s)", version, build_date); free(version); // первый запуск: узел ещё не назван — сразу открываем мастер настройки if (_node_prefs != nullptr && _node_prefs->node_name[0] == 0) { openMenu(); } } int UITask::rowHeight() const { return _display->height() >= 48 ? 10 : 8; } bool UITask::runCommand(const char* command) { _toast[0] = 0; if (_cli == nullptr) { setToast("no CLI"); return false; } char buf[96]; strncpy(buf, command, sizeof(buf) - 1); buf[sizeof(buf) - 1] = 0; char reply[128]; reply[0] = 0; _cli->handleCommand(0, buf, reply); if (reply[0] == 0) { setToast("OK"); return true; } bool ok = !(reply[0] == 'E' || reply[0] == 'e'); // "Error..." / "OK..." setToast(reply); return ok; } void UITask::setToast(const char* msg) { strncpy(_toast, msg, sizeof(_toast) - 1); _toast[sizeof(_toast) - 1] = 0; _toast_until = millis() + TOAST_MILLIS; } void UITask::openMenu() { _screen = SCREEN_MENU; _menu_index = 0; _menu_top = 0; _auto_off = millis() + WIZARD_AUTO_OFF_MILLIS; } // --- радиопараметры --------------------------------------------------------- void UITask::loadRadio() { if (_radio_loaded || _node_prefs == nullptr) return; _radio_freq = _node_prefs->freq; _radio_bw = _node_prefs->bw; _radio_sf = _node_prefs->sf; _radio_cr = _node_prefs->cr; _radio_loaded = true; } void UITask::applyRadio() { char cmd[96]; snprintf(cmd, sizeof(cmd), "set radio %.3f,%.2f,%d,%d", _radio_freq, _radio_bw, _radio_sf, _radio_cr); runCommand(cmd); } void UITask::beginFreqEdit() { loadRadio(); for (int i = 0; i < 4; i++) _freq_applied[i] = false; _freq_index = 0; _freq_editing = false; double v = _radio_freq < 0 ? -_radio_freq : _radio_freq; int mhz = (int)v; int khz = (int)((v - mhz) * 1000.0 + 0.5); if (khz > 999) { khz = 0; mhz++; } _freq_mhz = mhz; _freq_khz[0] = (char)('0' + (khz / 100) % 10); _freq_khz[1] = (char)('0' + (khz / 10) % 10); _freq_khz[2] = (char)('0' + khz % 10); _screen = SCREEN_FREQ; } double UITask::freqValue() const { int khz = (_freq_khz[0] - '0') * 100 + (_freq_khz[1] - '0') * 10 + (_freq_khz[2] - '0'); return _freq_mhz + khz / 1000.0; } void UITask::beginBwEdit() { loadRadio(); _digit_int = 3; _digit_count = 5; // xxx.xx, например 062.50 for (int i = 0; i < _digit_count; i++) _digit_applied[i] = false; _digit_index = 0; _digit_editing = false; double v = _radio_bw < 0 ? -_radio_bw : _radio_bw; int ip = (int)v; int fp = (int)((v - ip) * 100.0 + 0.5); if (fp > 99) { fp = 0; ip++; } char buf[8]; snprintf(buf, sizeof(buf), "%03d%02d", ip % 1000, fp); memcpy(_digit_digits, buf, _digit_count); _screen = SCREEN_DIGITS; } double UITask::digitValue() const { int ip = 0, fp = 0; for (int i = 0; i < _digit_int; i++) ip = ip * 10 + (_digit_digits[i] - '0'); for (int i = _digit_int; i < _digit_count; i++) fp = fp * 10 + (_digit_digits[i] - '0'); double scale = 1; for (int i = _digit_int; i < _digit_count; i++) scale *= 10; return ip + fp / scale; } // --- меню ------------------------------------------------------------------- void UITask::openField(uint8_t index) { _menu_index = index; _auto_off = millis() + WIZARD_AUTO_OFF_MILLIS; switch (index) { case MENU_COORDS: coordFromPrefs(); _screen = SCREEN_COORDS; return; case MENU_FREQ: beginFreqEdit(); return; case MENU_BW: beginBwEdit(); return; case MENU_CR: loadRadio(); _value = _radio_cr; break; case MENU_SF: loadRadio(); _value = _radio_sf; break; case MENU_TX: _value = _node_prefs ? _node_prefs->tx_power_dbm : 0; break; case MENU_REPEAT: _value = (_node_prefs && _node_prefs->disable_fwd) ? 0 : 1; break; case MENU_PATHHASH: _value = _node_prefs ? _node_prefs->path_hash_mode : 0; break; case MENU_ADVERT: _value = _node_prefs ? _node_prefs->advert_interval * 2 : 0; break; case MENU_FLOODADV: _value = _node_prefs ? _node_prefs->flood_advert_interval : 0; break; case MENU_FLOODMAX: _value = _node_prefs ? _node_prefs->flood_max : 0; break; default: _screen = SCREEN_STATUS; return; } _screen = SCREEN_VALUE; } void UITask::applyField() { char cmd[96]; switch (_menu_index) { case MENU_CR: _radio_cr = _value; applyRadio(); return; case MENU_SF: _radio_sf = _value; applyRadio(); return; case MENU_TX: snprintf(cmd, sizeof(cmd), "set tx %d", _value); break; case MENU_REPEAT: snprintf(cmd, sizeof(cmd), "set repeat %s", _value ? "on" : "off"); break; case MENU_PATHHASH: snprintf(cmd, sizeof(cmd), "set path.hash.mode %d", _value); break; case MENU_ADVERT: snprintf(cmd, sizeof(cmd), "set advert.interval %d", _value); break; case MENU_FLOODADV: snprintf(cmd, sizeof(cmd), "set flood.advert.interval %d", _value); break; case MENU_FLOODMAX: snprintf(cmd, sizeof(cmd), "set flood.max %d", _value); break; default: return; } runCommand(cmd); } // --- координаты ------------------------------------------------------------- void UITask::coordFromPrefs() { for (int i = 0; i < 12; i++) _coord_applied[i] = false; _coord_digit = 0; _coord_editing = false; double lat = _node_prefs ? _node_prefs->node_lat : 0; double lon = _node_prefs ? _node_prefs->node_lon : 0; if (lat < 0) lat = -lat; if (lon < 0) lon = -lon; int ldeg = (int)lat; int lfrac = (int)((lat - ldeg) * 10000.0 + 0.5); int odeg = (int)lon; int ofrac = (int)((lon - odeg) * 10000.0 + 0.5); if (lfrac > 9999) { lfrac = 0; ldeg++; } if (ofrac > 9999) { ofrac = 0; odeg++; } char buf[16]; snprintf(buf, sizeof(buf), "%02d%04d%02d%04d", ldeg % 100, lfrac, odeg % 100, ofrac); memcpy(_coord_digits, buf, 12); } double UITask::coordAxisValue(uint8_t axis) const { const char* d = &_coord_digits[axis * 6]; int deg = (d[0] - '0') * 10 + (d[1] - '0'); int frac = (d[2] - '0') * 1000 + (d[3] - '0') * 100 + (d[4] - '0') * 10 + (d[5] - '0'); return deg + frac / 10000.0; } void UITask::coordSave() { char cmd[64]; snprintf(cmd, sizeof(cmd), "set lat %.4f", coordAxisValue(0)); bool okLat = runCommand(cmd); char latMsg[sizeof(_toast)]; strncpy(latMsg, _toast, sizeof(latMsg) - 1); latMsg[sizeof(latMsg) - 1] = 0; snprintf(cmd, sizeof(cmd), "set lon %.4f", coordAxisValue(1)); bool okLon = runCommand(cmd); if (okLat && okLon) { setToast("coords saved"); } else { setToast(latMsg[0] ? latMsg : _toast); } } // --- кнопка ----------------------------------------------------------------- void UITask::handleButton(int ev) { switch (_screen) { case SCREEN_STATUS: if (ev == BUTTON_EVENT_CLICK) { if (!_display->isOn()) _display->turnOn(); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { openMenu(); } else if (ev == BUTTON_EVENT_LONG_PRESS) { _display->turnOn(); Serial.println("Powering Off"); _powering_off_at = millis() + POWEROFF_DELAY; } break; case SCREEN_MENU: if (ev == BUTTON_EVENT_CLICK) { _menu_index = (uint8_t)((_menu_index + 1) % MENU_COUNT); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { _menu_index = (uint8_t)((_menu_index + MENU_COUNT - 1) % MENU_COUNT); } else if (ev == BUTTON_EVENT_LONG_PRESS) { if (_menu_index == MENU_EXIT) { applyRadio(); // на случай правок частоты/полосы/SF/CR _screen = SCREEN_STATUS; } else { openField(_menu_index); } } break; case SCREEN_VALUE: if (ev == BUTTON_EVENT_CLICK || ev == BUTTON_EVENT_DOUBLE_CLICK) { int dir = (ev == BUTTON_EVENT_CLICK) ? 1 : -1; switch (_menu_index) { case MENU_CR: stepClamped(_value, dir, 5, 8, 1); break; case MENU_SF: stepClamped(_value, dir, 5, 12, 1); break; case MENU_REPEAT: _value = _value ? 0 : 1; break; case MENU_PATHHASH: _value = (_value + 3 + dir) % 3; break; case MENU_TX: stepClamped(_value, dir, 1, 30, 1); break; case MENU_ADVERT: if (_value == 0 && dir > 0) _value = 60; else if (_value <= 60 && dir < 0) _value = 0; else stepClamped(_value, dir, 60, 240, 10); break; case MENU_FLOODADV: stepClamped(_value, dir, 3, 168, 1); break; case MENU_FLOODMAX: stepClamped(_value, dir, 0, 64, 1); break; } } else if (ev == BUTTON_EVENT_LONG_PRESS) { applyField(); _screen = SCREEN_MENU; } break; case SCREEN_DIGITS: { // полоса: цифры + пункт Save & exit последней позицией uint8_t positions = (uint8_t)(_digit_count + 1); if (_digit_editing) { if (ev == BUTTON_EVENT_CLICK) { int d = _digit_digits[_digit_index] - '0'; _digit_digits[_digit_index] = (char)('0' + (d + 1) % 10); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { int d = _digit_digits[_digit_index] - '0'; _digit_digits[_digit_index] = (char)('0' + (d + 9) % 10); } else if (ev == BUTTON_EVENT_LONG_PRESS) { _digit_editing = false; _digit_applied[_digit_index] = true; } } else if (ev == BUTTON_EVENT_LONG_PRESS) { if (_digit_index >= _digit_count) { // Save & exit _radio_bw = digitValue(); applyRadio(); _screen = SCREEN_MENU; } else { _digit_editing = true; } } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { _digit_index = (uint8_t)((_digit_index + positions - 1) % positions); } else if (ev == BUTTON_EVENT_CLICK) { _digit_index = (uint8_t)((_digit_index + 1) % positions); } break; } case SCREEN_FREQ: { // частота: МГц шагом 1, кГц по разрядам, затем Save & exit if (_freq_editing) { if (_freq_index == 0) { if (ev == BUTTON_EVENT_CLICK) { if (_freq_mhz < 2500) _freq_mhz++; } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { if (_freq_mhz > 150) _freq_mhz--; } else if (ev == BUTTON_EVENT_LONG_PRESS) { _freq_editing = false; _freq_applied[0] = true; } } else { uint8_t k = (uint8_t)(_freq_index - 1); if (ev == BUTTON_EVENT_CLICK) { int d = _freq_khz[k] - '0'; _freq_khz[k] = (char)('0' + (d + 1) % 10); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { int d = _freq_khz[k] - '0'; _freq_khz[k] = (char)('0' + (d + 9) % 10); } else if (ev == BUTTON_EVENT_LONG_PRESS) { _freq_editing = false; _freq_applied[_freq_index] = true; } } } else if (ev == BUTTON_EVENT_LONG_PRESS) { if (_freq_index == FREQ_SAVE_POS) { // Save & exit _radio_freq = freqValue(); applyRadio(); _screen = SCREEN_MENU; } else { _freq_editing = true; } } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { _freq_index = (uint8_t)((_freq_index + FREQ_POSITIONS - 1) % FREQ_POSITIONS); } else if (ev == BUTTON_EVENT_CLICK) { _freq_index = (uint8_t)((_freq_index + 1) % FREQ_POSITIONS); } break; } case SCREEN_COORDS: if (_coord_editing) { // правка текущей цифры: короткое +1, двойное -1, длинное — применить if (ev == BUTTON_EVENT_CLICK) { int d = _coord_digits[_coord_digit] - '0'; _coord_digits[_coord_digit] = (char)('0' + (d + 1) % 10); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { int d = _coord_digits[_coord_digit] - '0'; _coord_digits[_coord_digit] = (char)('0' + (d + 9) % 10); } else if (ev == BUTTON_EVENT_LONG_PRESS) { _coord_editing = false; _coord_applied[_coord_digit] = true; } } else { // цифра применена: длинное — войти в правку, короткое — следующая цифра if (ev == BUTTON_EVENT_LONG_PRESS) { _coord_editing = true; } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { _coord_digit = (uint8_t)((_coord_digit + 11) % 12); } else if (ev == BUTTON_EVENT_CLICK) { bool allApplied = true; for (int i = 0; i < 12; i++) { if (!_coord_applied[i]) { allApplied = false; break; } } if (_coord_digit == 11 && allApplied) { _screen = SCREEN_COORDS_SAVE; } else { _coord_digit = (uint8_t)((_coord_digit + 1) % 12); } } } break; case SCREEN_COORDS_SAVE: if (ev == BUTTON_EVENT_LONG_PRESS) { coordSave(); _screen = SCREEN_MENU; } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { _screen = SCREEN_COORDS; } break; } } void UITask::loop() { #if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) int ev = user_btn.check(); if (ev != BUTTON_EVENT_NONE) { handleButton(ev); _auto_off = millis() + (wizardActive() ? WIZARD_AUTO_OFF_MILLIS : AUTO_OFF_MILLIS); } #endif if (_display->isOn()) { if (millis() >= _next_refresh) { _display->startFrame(); renderCurrScreen(); _display->endFrame(); _next_refresh = millis() + 1000; // refresh every second } if (millis() > _auto_off) { _display->turnOff(); } } if (_powering_off_at > 0) { // power off timer armed #ifdef LED_PIN digitalWrite(LED_PIN, LED_STATE_ON); // switch on the led until poweroff #endif if (millis() > _powering_off_at) { _board->powerOff(); // should not return } } } // --- отрисовка -------------------------------------------------------------- void UITask::renderTitleBar(const char* title) { _display->setColor(UIColor::primary_txt); _display->fillRect(0, 0, _display->width(), 11); _display->setColor(UIColor::window_bkg); _display->setTextSize(1); _display->setCursor(1, 2); _display->print(title); _display->setColor(UIColor::primary_txt); } void UITask::renderHint(const char* hint) { int y = _display->height() - 9; if (y < 11) return; _display->setColor(UIColor::primary_txt); _display->setTextSize(1); _display->setCursor(0, y); _display->print(hint); } void UITask::renderToast() { if (_toast[0] == 0 || millis() > _toast_until) return; int w = _display->width(); int h = _display->height(); _display->setColor(UIColor::primary_txt); _display->drawRect(0, 12, w, h - 12); _display->setTextSize(1); _display->setCursor(2, 15); _display->printWordWrap(_toast, w - 4); } void UITask::renderMenu() { renderTitleBar("Setup"); int w = _display->width(); int h = _display->height(); int rowH = rowHeight(); int rows = (h - 11 - 10) / rowH; if (rows < 1) rows = 1; if (_menu_index < _menu_top) _menu_top = _menu_index; if (_menu_index >= _menu_top + rows) _menu_top = (uint8_t)(_menu_index - rows + 1); _display->setTextSize(1); for (int i = 0; i < rows; i++) { int idx = _menu_top + i; if (idx >= MENU_COUNT) break; int y = 13 + i * rowH; if (idx == _menu_index) { _display->setColor(UIColor::primary_txt); _display->fillRect(0, y - 1, w, rowH); _display->setColor(UIColor::window_bkg); } else { _display->setColor(UIColor::primary_txt); } _display->setCursor(1, y); _display->print(MENU_NAMES[idx]); } _display->setColor(UIColor::primary_txt); renderHint("click=next hold=sel"); renderToast(); } void UITask::renderValue() { char value[40]; const char* title = MENU_NAMES[_menu_index]; switch (_menu_index) { case MENU_CR: snprintf(value, sizeof(value), "%d", _value); break; case MENU_SF: snprintf(value, sizeof(value), "%d", _value); break; case MENU_TX: snprintf(value, sizeof(value), "%d dBm", _value); break; case MENU_REPEAT: snprintf(value, sizeof(value), "%s", _value ? "ON" : "OFF"); break; case MENU_PATHHASH: snprintf(value, sizeof(value), "%s", PATH_HASH_NAMES[_value % 3]); break; case MENU_ADVERT: if (_value == 0) snprintf(value, sizeof(value), "off"); else snprintf(value, sizeof(value), "%d min", _value); break; case MENU_FLOODADV: snprintf(value, sizeof(value), "%d h", _value); break; case MENU_FLOODMAX: snprintf(value, sizeof(value), "%d", _value); break; default: value[0] = 0; break; } renderTitleBar(title); _display->setTextSize(1); _display->setColor(UIColor::primary_txt); _display->setCursor(2, 20); _display->print(value); renderHint("clk+ dbl- hold=OK"); renderToast(); } // поразрядный ввод значения с точкой (полоса): "062.50" + пункт Save & exit void UITask::renderDigits() { renderTitleBar("Bandwidth"); _display->setTextSize(1); int y = 18; int cx = 2; for (int i = 0; i < _digit_count; i++) { if (i == _digit_int) { _display->setColor(UIColor::primary_txt); _display->setCursor(cx, y); _display->print("."); cx += CHAR_W; } char c[2] = { _digit_digits[i], 0 }; if (i == _digit_index) { _display->setColor(UIColor::primary_txt); _display->fillRect(cx, y - 1, CHAR_W, 9); _display->setColor(UIColor::window_bkg); } else { _display->setColor(UIColor::primary_txt); } _display->setCursor(cx, y); _display->print(c); _display->setColor(UIColor::primary_txt); cx += CHAR_W; } _display->setColor(UIColor::primary_txt); _display->setCursor(cx + CHAR_W, y); _display->print("kHz"); renderRow(_display, 34, "Save & exit", _digit_index >= _digit_count); if (_digit_editing) { renderHint("clk+ dbl- hold=set"); } else if (_digit_index >= _digit_count) { renderHint("hold=save & exit"); } else { renderHint("hold=edit clk=next"); } renderToast(); } // частота: целые МГц выделяются целиком (шаг 1), кГц — по разрядам, затем Save & exit void UITask::renderFreq() { renderTitleBar("Frequency"); _display->setTextSize(1); int y = 18; int cx = 2; char mhz[8]; snprintf(mhz, sizeof(mhz), "%d", _freq_mhz); int mhzLen = (int)strlen(mhz); if (_freq_index == FREQ_MHZ_POS) { _display->setColor(UIColor::primary_txt); _display->fillRect(cx, y - 1, mhzLen * CHAR_W, 9); _display->setColor(UIColor::window_bkg); } else { _display->setColor(UIColor::primary_txt); } _display->setCursor(cx, y); _display->print(mhz); _display->setColor(UIColor::primary_txt); cx += mhzLen * CHAR_W; _display->setCursor(cx, y); _display->print("."); cx += CHAR_W; for (int i = 0; i < 3; i++) { char c[2] = { _freq_khz[i], 0 }; if (_freq_index == (uint8_t)(i + 1)) { _display->setColor(UIColor::primary_txt); _display->fillRect(cx, y - 1, CHAR_W, 9); _display->setColor(UIColor::window_bkg); } else { _display->setColor(UIColor::primary_txt); } _display->setCursor(cx, y); _display->print(c); _display->setColor(UIColor::primary_txt); cx += CHAR_W; } // единица измерения в конце строки значения _display->setColor(UIColor::primary_txt); _display->setCursor(cx + CHAR_W, y); _display->print("MHz"); renderRow(_display, 34, "Save & exit", _freq_index == FREQ_SAVE_POS); if (_freq_editing) { renderHint(_freq_index == FREQ_MHZ_POS ? "clk +1MHz dbl -1" : "clk+ dbl- hold=set"); } else if (_freq_index == FREQ_SAVE_POS) { renderHint("hold=save & exit"); } else { renderHint("hold=edit clk=next"); } renderToast(); } void UITask::renderCoords() { char text[10]; renderTitleBar("Coordinates"); for (int axis = 0; axis < 2; axis++) { const char* d = &_coord_digits[axis * 6]; snprintf(text, sizeof(text), "%c%c.%c%c%c%c", d[0], d[1], d[2], d[3], d[4], d[5]); int y = 18 + axis * 12; _display->setTextSize(1); for (int i = 0; i < 7; i++) { int digitIndex = -1; for (int k = 0; k < 6; k++) { if (COORD_POS[k] == i) { digitIndex = k; break; } } int x = i * CHAR_W; bool active = (digitIndex >= 0) && ((axis * 6 + digitIndex) == _coord_digit); char c[2] = { text[i], 0 }; if (active) { _display->setColor(UIColor::primary_txt); _display->fillRect(x, y - 1, CHAR_W, 9); _display->setColor(UIColor::window_bkg); _display->setCursor(x, y); _display->print(c); _display->setColor(UIColor::primary_txt); } else { _display->setColor(UIColor::primary_txt); _display->setCursor(x, y); _display->print(c); } } } if (_coord_editing) { renderHint("clk+ dbl- hold=set"); } else { renderHint("hold=edit clk=next"); } renderToast(); } void UITask::renderCoordsSave() { renderTitleBar("Coordinates"); _display->setTextSize(1); _display->setColor(UIColor::primary_txt); _display->setCursor(2, 20); _display->print("Save & exit?"); renderHint("hold=save dbl=cancel"); renderToast(); } void UITask::renderStatus() { char tmp[80]; _display->setCursor(0, 0); _display->setTextSize(1); _display->setColor(UIColor::primary_txt); _display->print(_node_prefs->node_name); // freq / sf _display->setCursor(0, 20); sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); _display->print(tmp); // bw / cr _display->setCursor(0, 30); sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); _display->print(tmp); // WiFi IP (только там, где Wi-Fi вообще есть) _display->setCursor(0, 40); #if defined(ESP32) if (WiFi.status() == WL_CONNECTED) { IPAddress ip = WiFi.localIP(); snprintf(tmp, sizeof(tmp), "IP: %u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]); } else { snprintf(tmp, sizeof(tmp), "IP: -"); } #else snprintf(tmp, sizeof(tmp), "IP: -"); #endif _display->print(tmp); if (_display->height() >= 56) { renderHint("dbl-click: setup"); } renderToast(); } void UITask::renderCurrScreen() { if (millis() < _started_at + BOOT_SCREEN_MILLIS) { // boot screen // 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); _display->drawTextCentered(_display->width() / 2, 22, website); // version info _display->setTextSize(1); _display->drawTextCentered(_display->width() / 2, 35, _version_info); // node type const char* node_type = "< Repeater >"; _display->drawTextCentered(_display->width() / 2, 48, node_type); return; } if (_powering_off_at > 0) { _display->setColor(UIColor::corp_blue); int logoWidth = 128; _display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); const char* website = "https://meshcore.io"; _display->setColor(UIColor::primary_txt); _display->setTextSize(1); _display->drawTextCentered(_display->width() / 2, 22, website); const char* poweroff_string = "Turning OFF"; _display->drawTextCentered(_display->width() / 2, 48, poweroff_string); return; } switch (_screen) { case SCREEN_MENU: renderMenu(); break; case SCREEN_VALUE: renderValue(); break; case SCREEN_DIGITS: renderDigits(); break; case SCREEN_FREQ: renderFreq(); break; case SCREEN_COORDS: renderCoords(); break; case SCREEN_COORDS_SAVE: renderCoordsSave(); break; default: renderStatus(); break; } }