From 71f7095d171d5895e2aa13a613d669652cbe8df4 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 11 Oct 2026 17:44:41 +0300 Subject: [PATCH] feat: on-device setup wizard for repeaters with display and button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - добавлен локальный мастер настройки репитера: examples/simple_repeater/UITask.* (меню на кнопке, редактор координат, применение значений через CommonCLI) - MyMesh: публичный доступ к CommonCLI для UI (getCLI), main.cpp передаёт его в UITask - мастер работает на любой цели с дисплеем и кнопкой и исключается препроцессором там, где их нет; WiFi-код теперь под #if defined(ESP32) - новая страница документации posadmesh-docs/device-setup.md, ссылки в index.md и mkdocs.yml - README: раздел «Настройка с дисплея и кнопки» и запись в журнале изменений - проверено сборкой: heltec_v4_repeater_mqtt, Meshadventurer_sx1262_repeater_mqtt, Generic_E22_sx1262_repeater_mqtt (без дисплея), RAK_3112_repeater_mqtt --- README.md | 18 + examples/simple_repeater/MyMesh.h | 3 + examples/simple_repeater/UITask.cpp | 628 ++++++++++++++++++++++++---- examples/simple_repeater/UITask.h | 56 ++- examples/simple_repeater/main.cpp | 2 +- mkdocs.yml | 1 + posadmesh-docs/device-setup.md | 65 +++ posadmesh-docs/index.md | 1 + 8 files changed, 701 insertions(+), 73 deletions(-) create mode 100644 posadmesh-docs/device-setup.md diff --git a/README.md b/README.md index 7d2e411e..b2cba8d6 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,14 @@ get bridge.enabled Рекомендуется использовать для первичной настройки и периодической диагностики. При развёртывании MQTT-репитеров отключайте панель по завершении, если нужен максимальный запас heap для MQTT. +### Настройка с дисплея и кнопки + +На платах с дисплеем и кнопкой мастер настройки доступен прямо на устройстве — без компьютера и телефона: двойное нажатие кнопки из экрана состояния открывает меню. Меняются координаты, радиопресет сети MeshCoreTel, мощность передатчика, пересылка пакетов, размер хэша пути, интервалы advert и flood advert, лимит flood. Значения применяются через тот же обработчик, что и последовательный CLI, поэтому проверка и сохранение одинаковы для обоих путей, а ответ устройства показывается на экране. + +Координаты вводятся в формате `xx.xxxx yy.yyyy`: длинное нажатие входит в правку цифры, короткое — `+1`, двойное — `−1`, длинное — применить, короткое после применения — перейти к следующей цифре, в конце экран подтверждения. + +Подробное описание кнопок, меню и ограничений — в [Настройке с дисплея и кнопки](./posadmesh-docs/device-setup.md). + ## Сборка и релизы Репозиторий живёт на . @@ -362,6 +370,16 @@ MQTT-репитер использует: - команды сборки: `build-repeater-mqtt-firmwares`, `build-repeater-mqtt-espnow-firmwares`, `build-repeater-mqtt-bridge-firmwares`; добавлены `build-mqtt-firmwares` (все MQTT-треки) и `list-mqtt`; - из документации, `release-notes.yml` и шаблонов issue убраны старые имена (историю релизов с реальными git-тегами не переписывали). +**Локальный мастер настройки** + +- добавлен мастер настройки репитера ([`examples/simple_repeater/UITask.*`](./examples/simple_repeater/UITask.cpp)): работает на любой цели, у которой есть дисплей и кнопка, и полностью исключается препроцессором там, где их нет; +- вход в мастер — двойное нажатие кнопки из экрана состояния (на узле без имени мастер открывается сам после заставки); выход — пункт `Save & exit`; +- ввод координат в формате `xx.xxxx yy.yyyy`: длинное нажатие — правка текущей цифры, короткое — `+1`, двойное — `−1`, длинное — применить, короткое после применения — следующая цифра; после всех 12 цифр — экран сохранения; +- остальные пункты: радиопресет MeshCoreTel (868,731 МГц, 62,5 кГц, SF7, CR7), мощность TX, пересылка пакетов, размер хэша пути, интервалы advert и flood advert, лимит flood; +- значения применяются через тот же `CommonCLI`, что и последовательный порт, а ответ устройства (`OK` или текст ошибки) выводится на экран; +- документация — [Настройка с дисплея и кнопки](./posadmesh-docs/device-setup.md); +- сборка проверена на `heltec_v4_repeater_mqtt`, `Meshadventurer_sx1262_repeater_mqtt`, `Generic_E22_sx1262_repeater_mqtt` (без дисплея) и `RAK_3112_repeater_mqtt`. + **Исправлено** - `set mqtt.meshcoretel on|off` не срабатывал: обработчик сравнивался со строкой `set mqtt.mqtt.meshcoretel `. Алиас `set mqtt.meshcoretel.ru on|off` читает значение по правильному смещению; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index ef722a20..74ccf689 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -319,6 +319,9 @@ public: NodePrefs* getNodePrefs() { return &_prefs; } + // Локальному мастеру настройки (UITask) нужен тот же обработчик команд, что и + // последовательному порту: проверка значений и сохранение остаются в CommonCLI. + CommonCLI& getCLI() { return _cli; } void savePrefs() override { _cli.savePrefs(_fs); diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 5700d17c..683e92f0 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -1,17 +1,55 @@ #include "UITask.h" #include "target.h" #include -#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 BOOT_SCREEN_MILLIS 4000 // 4 seconds +#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 POWEROFF_DELAY 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 = { @@ -30,11 +68,19 @@ static const uint8_t meshcore_logo [] PROGMEM = { 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8, }; -void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) { +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) @@ -52,88 +98,294 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi // 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(); + } } -void UITask::renderCurrScreen() { - char tmp[80]; - 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); +int UITask::rowHeight() const { + return _display->height() >= 48 ? 10 : 8; +} - // meshcore website - const char* website = "https://meshcore.io"; - _display->setColor(UIColor::primary_txt); - _display->setTextSize(1); - _display->drawTextCentered(_display->width() / 2, 22, website); +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; +} - // version info - _display->setTextSize(1); - _display->drawTextCentered(_display->width() / 2, 35, _version_info); +void UITask::setToast(const char* msg) { + strncpy(_toast, msg, sizeof(_toast) - 1); + _toast[sizeof(_toast) - 1] = 0; + _toast_until = millis() + TOAST_MILLIS; +} - // node type - const char* node_type = "< Repeater >"; - _display->drawTextCentered(_display->width() / 2, 48, node_type); - } else if (_powering_off_at > 0) { - // meshcore logo - _display->setColor(UIColor::corp_blue); - int logoWidth = 128; - _display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); +void UITask::openMenu() { + _screen = SCREEN_MENU; + _menu_index = 0; + _menu_top = 0; + _auto_off = millis() + WIZARD_AUTO_OFF_MILLIS; +} - // meshcore website - const char* website = "https://meshcore.io"; - _display->setColor(UIColor::primary_txt); - _display->setTextSize(1); - _display->drawTextCentered(_display->width()/ 2, 22, website); +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; +} - // Powering off - const char* poweroff_string = "Turning OFF"; - uint16_t poffWidth = _display->getTextWidth(poweroff_string); - _display->setCursor((_display->width() - poffWidth) / 2, 48); - _display->drawTextCentered(_display->width()/2, 48, poweroff_string); +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 { - _display->setCursor(0, 0); - _display->setTextSize(1); - _display->setColor(UIColor::primary_txt); - _display->print(_node_prefs->node_name); + setToast(latMsg[0] ? latMsg : _toast); + } +} - // freq / sf - _display->setCursor(0, 20); - sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); - _display->print(tmp); +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; - // bw / cr - _display->setCursor(0, 30); - sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); - _display->print(tmp); + 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; - // WiFi IP - _display->setCursor(0, 40); - 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: -"); - } - _display->print(tmp); + 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_CLICK) { - if (_display->isOn()) { - // TODO: any action ? - } else { - _display->turnOn(); - } - _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer - } else if (ev == BUTTON_EVENT_LONG_PRESS) { - _display->turnOn(); - Serial.println("Powering Off"); - _powering_off_at = millis() + POWEROFF_DELAY; + if (ev != BUTTON_EVENT_NONE) { + handleButton(ev); + _auto_off = millis() + (wizardActive() ? WIZARD_AUTO_OFF_MILLIS : AUTO_OFF_MILLIS); } #endif @@ -159,3 +411,239 @@ void UITask::loop() { } } } + +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; + } +} diff --git a/examples/simple_repeater/UITask.h b/examples/simple_repeater/UITask.h index d8e3ce1d..3dffc5a0 100644 --- a/examples/simple_repeater/UITask.h +++ b/examples/simple_repeater/UITask.h @@ -3,20 +3,72 @@ #include #include +class CommonCLI; + +// Локальный мастер настройки репитера. Работает на любой плате, у которой есть +// дисплей (DISPLAY_CLASS) и кнопка (PIN_USER_BTN); на остальных целях код +// полностью исключается препроцессором. Все изменения применяются через тот же +// CommonCLI, что и последовательный порт, поэтому проверка значений и запись в +// флеш выполняются одним и тем же кодом. class UITask { +public: + enum Screen : uint8_t { + SCREEN_STATUS = 0, + SCREEN_MENU, + SCREEN_VALUE, + SCREEN_COORDS, + SCREEN_COORDS_SAVE + }; + +private: mesh::MainBoard* _board; DisplayDriver* _display; unsigned long _next_read, _next_refresh, _auto_off; int _prevBtnState; NodePrefs* _node_prefs; + CommonCLI* _cli = nullptr; char _version_info[32]; unsigned long _powering_off_at = 0; unsigned long _started_at = 0; + // состояние мастера настройки + uint8_t _screen = SCREEN_STATUS; + uint8_t _menu_index = 0; + uint8_t _menu_top = 0; + int _value = 0; + uint8_t _coord_digit = 0; // 0..5 широта, 6..11 долгота + bool _coord_editing = false; // true: цифра правится, false: применена + bool _coord_applied[12]; + char _coord_digits[12]; + char _toast[40]; + unsigned long _toast_until = 0; + + bool wizardActive() const { return _screen != SCREEN_STATUS; } + void openMenu(); + void openField(uint8_t index); + void applyField(); + void coordFromPrefs(); + void coordSave(); + double coordAxisValue(uint8_t axis) const; // axis 0 = lat, 1 = lon + void setToast(const char* msg); + bool runCommand(const char* command); + + void handleButton(int ev); + void renderCurrScreen(); + void renderStatus(); + void renderMenu(); + void renderValue(); + void renderCoords(); + void renderCoordsSave(); + void renderTitleBar(const char* title); + void renderHint(const char* hint); + void renderToast(); + int rowHeight() const; + public: UITask(mesh::MainBoard& board, DisplayDriver& display) : _board(&board), _display(&display) { _next_read = _next_refresh = 0; } - void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version); + void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version, CommonCLI* cli = nullptr); void loop(); -}; \ No newline at end of file +}; diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 36c7b384..1f8fcd2c 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -117,7 +117,7 @@ void setup() { the_mesh.begin(fs, &archive); #ifdef DISPLAY_CLASS - ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); + ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION, &the_mesh.getCLI()); #endif #ifdef ETHERNET_ENABLED diff --git a/mkdocs.yml b/mkdocs.yml index 99496904..56e36aee 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,7 @@ nav: - Релизы: releases.md - Миграция: migration.md - Веб-панель: web-panel.md + - Настройка с дисплея: device-setup.md - API репитера: api.md - Локальные сборки: local-builds.md - Кастомный CLI: custom-cli.md diff --git a/posadmesh-docs/device-setup.md b/posadmesh-docs/device-setup.md new file mode 100644 index 00000000..40c3be12 --- /dev/null +++ b/posadmesh-docs/device-setup.md @@ -0,0 +1,65 @@ +# Настройка с дисплея и кнопки + +Мастер настройки работает прямо на репитере: не нужен ни компьютер, ни телефон. Он доступен на любой плате, у которой есть дисплей и кнопка, и отсутствует на платах без дисплея — там настройка выполняется через [последовательный CLI](./custom-cli.md) или [веб-панель](./web-panel.md). + +## Что настраивается + +Мастер меняет только то, что нужно для быстрого ввода узла в сеть: + +| Пункт меню | Что делает | Команда CLI | +| --- | --- | --- | +| `Coordinates` | широта и долгота узла | `set lat`, `set lon` | +| `Radio preset` | `keep current` или готовый пресет сети MeshCoreTel (868,731 МГц, 62,5 кГц, SF7, CR7) | `set radio` | +| `TX power` | мощность передатчика, 1–30 дБм | `set tx` | +| `Repeat` | пересылка пакетов включена или выключена | `set repeat` | +| `Path hash` | размер хэша пути: 0, 1 или 2 байта | `set path.hash.mode` | +| `Advert int` | интервал advert в минутах (0 — выключено, иначе 60–240) | `set advert.interval` | +| `Flood advert` | интервал flood advert в часах (3–168) | `set flood.advert.interval` | +| `Flood max` | лимит пересылки flood (0–64) | `set flood.max` | +| `Save & exit` | выход из мастера | — | + +Значения применяются сразу при выходе из пункта и сохраняются во флеш. Ответ устройства (`OK` или текст ошибки) показывается на экране — проверку выполняет тот же код, что и последовательный CLI, поэтому неверное значение не будет записано. + +## Управление кнопкой + +Экран состояния (обычный режим): + +- **короткое** — включить или продлить подсветку экрана; +- **двойное** — войти в мастер настройки; +- **длинное** — выключить питание узла. + +В меню: + +- **короткое** — следующий пункт, **двойное** — предыдущий; +- **длинное** — войти в пункт. + +В пункте с числом или списком: + +- **короткое** — увеличить значение, **двойное** — уменьшить; +- **длинное** — применить и вернуться в меню. + +Если узел прошит «с нуля» и ещё не имеет имени, мастер открывается сам после заставки. + +## Ввод координат + +Координаты показываются в формате `xx.xxxx yy.yyyy` — сначала широта, затем долгота. Активная цифра выделена инверсией. + +| Действие | Что происходит | +| --- | --- | +| длинное нажатие | войти в изменение текущей цифры | +| короткое нажатие в режиме правки | цифра +1 | +| двойное нажатие в режиме правки | цифра −1 | +| длинное нажатие в режиме правки | применить цифру | +| короткое нажатие после применения | перейти к следующей цифре | +| длинное нажатие на экране `Save & exit?` | сохранить координаты и выйти | +| двойное нажатие на экране `Save & exit?` | вернуться к правке | + +Всего перебираются 12 цифр: шесть для широты и шесть для долготы. Когда применены все, короткое нажатие выводит экран подтверждения. Координаты сохраняются двумя командами — `set lat` и `set lon`; после сохранения узел появится на карте MeshCoreTel в течение следующего advert. + +Ограничение: мастер вводит положительные координаты (формат без знака). Для южного полушария и западной долготы используйте CLI (`set lat -33.1234`) или веб-панель. + +## Ограничения + +- На экранах без кириллицы подписи меню выводятся латиницей. +- Мастер не настраивает Wi-Fi, MQTT, регионы и список доступа: для них нужен последовательный CLI или веб-панель. На плате с Wi-Fi их удобнее задавать из веб-панели. +- Если дисплея нет, мастер не компилируется в прошивку и на поведение узла не влияет. diff --git a/posadmesh-docs/index.md b/posadmesh-docs/index.md index 98536507..659ac001 100644 --- a/posadmesh-docs/index.md +++ b/posadmesh-docs/index.md @@ -37,6 +37,7 @@ MeshCore-Posadmesh сохраняет upstream-прошивку MeshCore без - [Сравнение плат](./boards.md) - [Скачивание и прошивка релизов](./releases.md) - [Миграция с xJARiD/MeshCore](./migration.md) +- [Настройка с дисплея и кнопки](./device-setup.md) - [Использование веб-панели репитера](./web-panel.md) - [Использование веб-API репитера](./api.md) - [Пользовательские CLI-команды](./custom-cli.md)