#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 // Пункты мастера. Подписи латиницей: шрифты драйверов не содержат кириллицы. enum { MENU_COORDS = 0, MENU_RADIO, 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", "Radio preset", "TX power", "Repeat", "Path hash", "Advert int", "Flood advert", "Flood max", "Save & exit" }; // 0 = не менять; MeshCoreTel MOW: 868.731 МГц, 62.5 кГц, SF7, CR7 static const char* const RADIO_PRESET_CMDS[] = { nullptr, "set radio 868.731,62.5,7,7" }; static const char* const RADIO_PRESET_NAMES[] = { "keep current", "MeshCoreTel MOW" }; 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; } 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::openField(uint8_t index) { _menu_index = index; switch (index) { case MENU_COORDS: coordFromPrefs(); _screen = SCREEN_COORDS; break; case MENU_RADIO: _value = 0; // keep current _screen = SCREEN_VALUE; break; case MENU_TX: _value = _node_prefs ? _node_prefs->tx_power_dbm : 0; _screen = SCREEN_VALUE; break; case MENU_REPEAT: _value = (_node_prefs && _node_prefs->disable_fwd) ? 0 : 1; _screen = SCREEN_VALUE; break; case MENU_PATHHASH: _value = _node_prefs ? _node_prefs->path_hash_mode : 0; _screen = SCREEN_VALUE; break; case MENU_ADVERT: _value = _node_prefs ? _node_prefs->advert_interval * 2 : 0; _screen = SCREEN_VALUE; break; case MENU_FLOODADV: _value = _node_prefs ? _node_prefs->flood_advert_interval : 0; _screen = SCREEN_VALUE; break; case MENU_FLOODMAX: _value = _node_prefs ? _node_prefs->flood_max : 0; _screen = SCREEN_VALUE; break; default: _screen = SCREEN_STATUS; break; } _auto_off = millis() + WIZARD_AUTO_OFF_MILLIS; } void UITask::applyField() { char cmd[96]; bool ok = false; switch (_menu_index) { case MENU_RADIO: if (_value <= 0 || RADIO_PRESET_CMDS[_value] == nullptr) { setToast("keep current"); return; } ok = runCommand(RADIO_PRESET_CMDS[_value]); break; case MENU_TX: snprintf(cmd, sizeof(cmd), "set tx %d", _value); ok = runCommand(cmd); break; case MENU_REPEAT: snprintf(cmd, sizeof(cmd), "set repeat %s", _value ? "on" : "off"); ok = runCommand(cmd); break; case MENU_PATHHASH: snprintf(cmd, sizeof(cmd), "set path.hash.mode %d", _value); ok = runCommand(cmd); break; case MENU_ADVERT: snprintf(cmd, sizeof(cmd), "set advert.interval %d", _value); ok = runCommand(cmd); break; case MENU_FLOODADV: snprintf(cmd, sizeof(cmd), "set flood.advert.interval %d", _value); ok = runCommand(cmd); break; case MENU_FLOODMAX: snprintf(cmd, sizeof(cmd), "set flood.max %d", _value); ok = runCommand(cmd); break; default: return; } (void)ok; } 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) { _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_RADIO: _value = (_value + 1) % 2; 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_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_RADIO: snprintf(value, sizeof(value), "%s", RADIO_PRESET_NAMES[_value % 2]); 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(); } 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 * 6; 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, 6, 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_COORDS: renderCoords(); break; case SCREEN_COORDS_SAVE: renderCoordsSave(); break; default: renderStatus(); break; } }