#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 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_BATTERY, MENU_ADC, 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", "Battery", "ADC", "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) { showPopup("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) { showPopup("OK"); return true; } bool ok = !(reply[0] == 'E' || reply[0] == 'e'); // "Error..." / "OK..." showPopup(reply); return ok; } // Всплывающее окно с ответом устройства: показывается поверх экрана мастера // и закрывается коротким нажатием кнопки (см. handleButton). void UITask::showPopup(const char* msg) { strncpy(_toast, msg, sizeof(_toast) - 1); _toast[sizeof(_toast) - 1] = 0; _popup_active = true; _next_refresh = 0; // показать окно сразу, не ждать тика отрисовки _auto_off = millis() + WIZARD_AUTO_OFF_MILLIS; } void UITask::openMenu() { _screen = SCREEN_MENU; _menu_index = 0; _menu_top = 0; _radio_loaded = false; // при каждом входе перечитываем радио из настроек узла _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; } // Отправка радиопараметров. Вызывается только когда их действительно правили: // иначе на выходе из мастера уходила команда с нулевыми значениями рабочей копии // и устройство отвечало "Error, invalid radio params". void UITask::applyRadio() { if (!_radio_dirty) return; loadRadio(); // на случай, если правка шла без открытия пункта char cmd[96]; snprintf(cmd, sizeof(cmd), "set radio %.3f,%.2f,%d,%d", _radio_freq, _radio_bw, _radio_sf, _radio_cr); if (runCommand(cmd)) { _radio_dirty = false; } } 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; } void UITask::beginAdcEdit() { // Множитель ADC: x.xx, где 0.00 означает «использовать заводское значение платы» _digit_int = 1; _digit_count = 3; for (int i = 0; i < _digit_count; i++) _digit_applied[i] = false; _digit_index = 0; _digit_editing = false; float v = _node_prefs ? _node_prefs->adc_multiplier : 0.0f; if (v < 0) v = 0; if (v > 9.99f) v = 9.99f; int ip = (int)v; int fp = (int)((v - ip) * 100.0f + 0.5f); if (fp > 99) { fp = 0; ip++; } char buf[8]; snprintf(buf, sizeof(buf), "%d%02d", ip % 10, 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; } // Применение поразрядного редактора: полоса (set radio) или множитель ADC void UITask::applyDigits() { if (_menu_index == MENU_ADC) { char cmd[48]; snprintf(cmd, sizeof(cmd), "set adc.multiplier %d.%02d", _digit_digits[0] - '0', (_digit_digits[1] - '0') * 10 + (_digit_digits[2] - '0')); runCommand(cmd); return; } _radio_bw = digitValue(); _radio_dirty = true; applyRadio(); } // Процент заряда: значение платы, а если его нет — линейная оценка по напряжению int UITask::batteryPercent() const { if (_board == nullptr) return -1; int pct = _board->getBatteryPercent(); if (pct >= 0) return pct > 100 ? 100 : pct; const uint16_t mv = _board->getBattMilliVolts(); const uint16_t lo = _board->getBatteryMinMilliVolts(); const uint16_t hi = _board->getBatteryMaxMilliVolts(); if (hi <= lo) return -1; if (mv <= lo) return 0; if (mv >= hi) return 100; return (int)(((uint32_t)(mv - lo) * 100U) / (uint32_t)(hi - lo)); } // --- меню ------------------------------------------------------------------- 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_BATTERY: _screen = SCREEN_BATTERY; return; case MENU_ADC: beginAdcEdit(); return; 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; _radio_dirty = true; applyRadio(); return; case MENU_SF: _radio_sf = _value; _radio_dirty = true; 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) { showPopup("coords saved"); } else { showPopup(latMsg[0] ? latMsg : _toast); } } // --- кнопка ----------------------------------------------------------------- void UITask::handleButton(int ev) { // Пока открыто всплывающее окно, оно модальное: любое нажатие только закрывает его // (короткое — как просили), экран под ним не меняется. if (_popup_active) { _popup_active = false; _next_refresh = 0; // сразу перерисовать экран под окном _auto_off = millis() + WIZARD_AUTO_OFF_MILLIS; return; } 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(); // применит радио, только если его правили в этой сессии _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 applyDigits(); _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_BATTERY: { // заряд аккумулятора: только просмотр if (ev == BUTTON_EVENT_CLICK) { char msg[32]; snprintf(msg, sizeof(msg), "raw %u mV", (unsigned)(_board ? _board->getBattMilliVolts() : 0)); showPopup(msg); } else if (ev == BUTTON_EVENT_LONG_PRESS || ev == BUTTON_EVENT_DOUBLE_CLICK) { _screen = SCREEN_MENU; } 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(); _radio_dirty = true; 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(); if (_popup_active) renderPopup(); // окно рисуется поверх экрана _display->endFrame(); _next_refresh = millis() + 1000; // refresh every second } if (millis() > _auto_off) { _display->turnOff(); } } if (_powering_off_at > 0) { // power off timer armed #if defined(LED_PIN) && defined(LED_STATE_ON) digitalWrite(LED_PIN, LED_STATE_ON); // switch on the led until poweroff delay(1000); digitalWrite(LED_PIN, !LED_STATE_ON); // off LED #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); } // Всплывающее окно: рамка, свой фон (чтобы текст не сливался со строками экрана) // и кнопка OK внизу. Рисуется поверх текущего экрана. void UITask::renderPopup() { const int w = _display->width(); const int h = _display->height(); int box_h = 46; if (box_h > h - 8) box_h = h - 8; int top = (h - box_h) / 2; if (top < 12) top = 12; // рамка: белая заливка и чёрная внутренняя область _display->setColor(UIColor::primary_txt); _display->fillRect(4, top, w - 8, box_h); _display->setColor(UIColor::window_bkg); _display->fillRect(6, top + 2, w - 12, box_h - 4); // текст сообщения _display->setColor(UIColor::primary_txt); _display->setTextSize(1); _display->setCursor(8, top + 5); _display->printWordWrap(_toast, w - 20); // кнопка OK const char* ok = "OK"; int ok_w = (int)strlen(ok) * CHAR_W + 10; int ok_x = (w - ok_w) / 2; int ok_y = top + box_h - 14; _display->setColor(UIColor::primary_txt); _display->drawRect(ok_x, ok_y, ok_w, 12); _display->setCursor(ok_x + 5, ok_y + 2); _display->print(ok); } 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"); } 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"); } // поразрядный ввод значения с точкой: полоса ("062.50") или множитель ADC ("0.00") void UITask::renderDigits() { const bool adc = (_menu_index == MENU_ADC); renderTitleBar(adc ? "ADC" : "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(adc ? "x" : "kHz"); // На экране ADC показываем измеренное напряжение: по нему видно результат калибровки if (adc) { const unsigned mv = _board ? _board->getBattMilliVolts() : 0; char volts[24]; snprintf(volts, sizeof(volts), "%u.%02u V", mv / 1000U, (mv % 1000U) / 10U); _display->setColor(UIColor::primary_txt); _display->setCursor(2, 45); _display->print(volts); } 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"); } } // заряд аккумулятора: напряжение, процент, полоска и текущий множитель ADC void UITask::renderBattery() { renderTitleBar("Battery"); _display->setTextSize(1); _display->setColor(UIColor::primary_txt); const unsigned mv = _board ? _board->getBattMilliVolts() : 0; char line[32]; snprintf(line, sizeof(line), "%u.%02u V", mv / 1000U, (mv % 1000U) / 10U); _display->setCursor(2, 15); _display->print(line); const int pct = batteryPercent(); if (pct >= 0) { snprintf(line, sizeof(line), "%d %%", pct); int x = _display->width() - (int)strlen(line) * CHAR_W - 2; if (x < 2) x = 2; _display->setCursor(x, 15); _display->print(line); } // полоска заряда const int bar_w = _display->width() - 4; _display->setColor(UIColor::primary_txt); _display->drawRect(2, 27, bar_w, 9); if (pct > 0) { int fill = (bar_w - 4) * (pct > 100 ? 100 : pct) / 100; if (fill > 0) _display->fillRect(4, 29, fill, 5); } const float adc = _node_prefs ? _node_prefs->adc_multiplier : 0.0f; if (adc > 0.0f) { snprintf(line, sizeof(line), "ADC %.3f", (double)adc); } else { snprintf(line, sizeof(line), "ADC default"); } _display->setColor(UIColor::primary_txt); _display->setCursor(2, 40); _display->print(line); renderHint("hold=menu clk=raw"); } // частота: целые МГц выделяются целиком (шаг 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"); } } 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"); } } 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"); } 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"); } } 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_BATTERY: renderBattery(); break; case SCREEN_COORDS: renderCoords(); break; case SCREEN_COORDS_SAVE: renderCoordsSave(); break; default: renderStatus(); break; } }