feat: on-device setup wizard for repeaters with display and button

- добавлен локальный мастер настройки репитера: 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
This commit is contained in:
2026-10-11 17:44:41 +03:00
parent 26b724c0f0
commit 71f7095d17
8 changed files with 701 additions and 73 deletions
+18
View File
@@ -302,6 +302,14 @@ get bridge.enabled
Рекомендуется использовать для первичной настройки и периодической диагностики. При развёртывании MQTT-репитеров отключайте панель по завершении, если нужен максимальный запас heap для MQTT. Рекомендуется использовать для первичной настройки и периодической диагностики. При развёртывании MQTT-репитеров отключайте панель по завершении, если нужен максимальный запас heap для MQTT.
### Настройка с дисплея и кнопки
На платах с дисплеем и кнопкой мастер настройки доступен прямо на устройстве — без компьютера и телефона: двойное нажатие кнопки из экрана состояния открывает меню. Меняются координаты, радиопресет сети MeshCoreTel, мощность передатчика, пересылка пакетов, размер хэша пути, интервалы advert и flood advert, лимит flood. Значения применяются через тот же обработчик, что и последовательный CLI, поэтому проверка и сохранение одинаковы для обоих путей, а ответ устройства показывается на экране.
Координаты вводятся в формате `xx.xxxx yy.yyyy`: длинное нажатие входит в правку цифры, короткое — `+1`, двойное — `−1`, длинное — применить, короткое после применения — перейти к следующей цифре, в конце экран подтверждения.
Подробное описание кнопок, меню и ограничений — в [Настройке с дисплея и кнопки](./posadmesh-docs/device-setup.md).
## Сборка и релизы ## Сборка и релизы
Репозиторий живёт на <https://git.meshinfo.ru/shade/MeshCore-Posadmesh>. Репозиторий живёт на <https://git.meshinfo.ru/shade/MeshCore-Posadmesh>.
@@ -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`; - команды сборки: `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-тегами не переписывали). - из документации, `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` читает значение по правильному смещению; - `set mqtt.meshcoretel on|off` не срабатывал: обработчик сравнивался со строкой `set mqtt.mqtt.meshcoretel `. Алиас `set mqtt.meshcoretel.ru on|off` читает значение по правильному смещению;
+3
View File
@@ -319,6 +319,9 @@ public:
NodePrefs* getNodePrefs() { NodePrefs* getNodePrefs() {
return &_prefs; return &_prefs;
} }
// Локальному мастеру настройки (UITask) нужен тот же обработчик команд, что и
// последовательному порту: проверка значений и сохранение остаются в CommonCLI.
CommonCLI& getCLI() { return _cli; }
void savePrefs() override { void savePrefs() override {
_cli.savePrefs(_fs); _cli.savePrefs(_fs);
+558 -70
View File
@@ -1,17 +1,55 @@
#include "UITask.h" #include "UITask.h"
#include "target.h" #include "target.h"
#include <Arduino.h> #include <Arduino.h>
#include <WiFi.h> #if defined(ESP32)
#include <WiFi.h>
#endif
#include <helpers/CommonCLI.h> #include <helpers/CommonCLI.h>
#include <helpers/ui/MomentaryButton.h>
#ifndef USER_BTN_PRESSED #ifndef USER_BTN_PRESSED
#define USER_BTN_PRESSED LOW #define USER_BTN_PRESSED LOW
#endif #endif
#define AUTO_OFF_MILLIS 20000 // 20 seconds #define AUTO_OFF_MILLIS 20000 // 20 seconds
#define BOOT_SCREEN_MILLIS 4000 // 4 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 // 'meshcore', 128x13px
static const uint8_t meshcore_logo [] PROGMEM = { 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, 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; _prevBtnState = HIGH;
_auto_off = millis() + AUTO_OFF_MILLIS; _auto_off = millis() + AUTO_OFF_MILLIS;
_started_at = millis(); _started_at = millis();
_node_prefs = node_prefs; _node_prefs = node_prefs;
_cli = cli;
_toast[0] = 0;
_display->turnOn(); _display->turnOn();
#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) #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) // v1.2.3 (1 Jan 2025)
snprintf(_version_info, sizeof(_version_info), "%s (%s)", version, build_date); snprintf(_version_info, sizeof(_version_info), "%s (%s)", version, build_date);
free(version); free(version);
// первый запуск: узел ещё не назван — сразу открываем мастер настройки
if (_node_prefs != nullptr && _node_prefs->node_name[0] == 0) {
openMenu();
}
} }
void UITask::renderCurrScreen() { int UITask::rowHeight() const {
char tmp[80]; return _display->height() >= 48 ? 10 : 8;
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 bool UITask::runCommand(const char* command) {
const char* website = "https://meshcore.io"; _toast[0] = 0;
_display->setColor(UIColor::primary_txt); if (_cli == nullptr) {
_display->setTextSize(1); setToast("no CLI");
_display->drawTextCentered(_display->width() / 2, 22, website); 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 void UITask::setToast(const char* msg) {
_display->setTextSize(1); strncpy(_toast, msg, sizeof(_toast) - 1);
_display->drawTextCentered(_display->width() / 2, 35, _version_info); _toast[sizeof(_toast) - 1] = 0;
_toast_until = millis() + TOAST_MILLIS;
}
// node type void UITask::openMenu() {
const char* node_type = "< Repeater >"; _screen = SCREEN_MENU;
_display->drawTextCentered(_display->width() / 2, 48, node_type); _menu_index = 0;
} else if (_powering_off_at > 0) { _menu_top = 0;
// meshcore logo _auto_off = millis() + WIZARD_AUTO_OFF_MILLIS;
_display->setColor(UIColor::corp_blue); }
int logoWidth = 128;
_display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
// meshcore website void UITask::openField(uint8_t index) {
const char* website = "https://meshcore.io"; _menu_index = index;
_display->setColor(UIColor::primary_txt); switch (index) {
_display->setTextSize(1); case MENU_COORDS:
_display->drawTextCentered(_display->width()/ 2, 22, website); 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 void UITask::applyField() {
const char* poweroff_string = "Turning OFF"; char cmd[96];
uint16_t poffWidth = _display->getTextWidth(poweroff_string); bool ok = false;
_display->setCursor((_display->width() - poffWidth) / 2, 48);
_display->drawTextCentered(_display->width()/2, 48, poweroff_string); 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 { } else {
_display->setCursor(0, 0); setToast(latMsg[0] ? latMsg : _toast);
_display->setTextSize(1); }
_display->setColor(UIColor::primary_txt); }
_display->print(_node_prefs->node_name);
// freq / sf void UITask::handleButton(int ev) {
_display->setCursor(0, 20); switch (_screen) {
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); case SCREEN_STATUS:
_display->print(tmp); 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 case SCREEN_MENU:
_display->setCursor(0, 30); if (ev == BUTTON_EVENT_CLICK) {
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); _menu_index = (uint8_t)((_menu_index + 1) % MENU_COUNT);
_display->print(tmp); } 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 case SCREEN_VALUE:
_display->setCursor(0, 40); if (ev == BUTTON_EVENT_CLICK || ev == BUTTON_EVENT_DOUBLE_CLICK) {
if (WiFi.status() == WL_CONNECTED) { int dir = (ev == BUTTON_EVENT_CLICK) ? 1 : -1;
IPAddress ip = WiFi.localIP(); switch (_menu_index) {
snprintf(tmp, sizeof(tmp), "IP: %u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]); case MENU_RADIO:
} else { _value = (_value + 1) % 2;
snprintf(tmp, sizeof(tmp), "IP: -"); break;
} case MENU_REPEAT:
_display->print(tmp); _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() { void UITask::loop() {
#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) #if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS)
int ev = user_btn.check(); int ev = user_btn.check();
if (ev == BUTTON_EVENT_CLICK) { if (ev != BUTTON_EVENT_NONE) {
if (_display->isOn()) { handleButton(ev);
// TODO: any action ? _auto_off = millis() + (wizardActive() ? WIZARD_AUTO_OFF_MILLIS : AUTO_OFF_MILLIS);
} 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;
} }
#endif #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;
}
}
+54 -2
View File
@@ -3,20 +3,72 @@
#include <helpers/ui/DisplayDriver.h> #include <helpers/ui/DisplayDriver.h>
#include <helpers/CommonCLI.h> #include <helpers/CommonCLI.h>
class CommonCLI;
// Локальный мастер настройки репитера. Работает на любой плате, у которой есть
// дисплей (DISPLAY_CLASS) и кнопка (PIN_USER_BTN); на остальных целях код
// полностью исключается препроцессором. Все изменения применяются через тот же
// CommonCLI, что и последовательный порт, поэтому проверка значений и запись в
// флеш выполняются одним и тем же кодом.
class UITask { class UITask {
public:
enum Screen : uint8_t {
SCREEN_STATUS = 0,
SCREEN_MENU,
SCREEN_VALUE,
SCREEN_COORDS,
SCREEN_COORDS_SAVE
};
private:
mesh::MainBoard* _board; mesh::MainBoard* _board;
DisplayDriver* _display; DisplayDriver* _display;
unsigned long _next_read, _next_refresh, _auto_off; unsigned long _next_read, _next_refresh, _auto_off;
int _prevBtnState; int _prevBtnState;
NodePrefs* _node_prefs; NodePrefs* _node_prefs;
CommonCLI* _cli = nullptr;
char _version_info[32]; char _version_info[32];
unsigned long _powering_off_at = 0; unsigned long _powering_off_at = 0;
unsigned long _started_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 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: public:
UITask(mesh::MainBoard& board, DisplayDriver& display) : _board(&board), _display(&display) { _next_read = _next_refresh = 0; } 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(); void loop();
}; };
+1 -1
View File
@@ -117,7 +117,7 @@ void setup() {
the_mesh.begin(fs, &archive); the_mesh.begin(fs, &archive);
#ifdef DISPLAY_CLASS #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 #endif
#ifdef ETHERNET_ENABLED #ifdef ETHERNET_ENABLED
+1
View File
@@ -40,6 +40,7 @@ nav:
- Релизы: releases.md - Релизы: releases.md
- Миграция: migration.md - Миграция: migration.md
- Веб-панель: web-panel.md - Веб-панель: web-panel.md
- Настройка с дисплея: device-setup.md
- API репитера: api.md - API репитера: api.md
- Локальные сборки: local-builds.md - Локальные сборки: local-builds.md
- Кастомный CLI: custom-cli.md - Кастомный CLI: custom-cli.md
+65
View File
@@ -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 их удобнее задавать из веб-панели.
- Если дисплея нет, мастер не компилируется в прошивку и на поведение узла не влияет.
+1
View File
@@ -37,6 +37,7 @@ MeshCore-Posadmesh сохраняет upstream-прошивку MeshCore без
- [Сравнение плат](./boards.md) - [Сравнение плат](./boards.md)
- [Скачивание и прошивка релизов](./releases.md) - [Скачивание и прошивка релизов](./releases.md)
- [Миграция с xJARiD/MeshCore](./migration.md) - [Миграция с xJARiD/MeshCore](./migration.md)
- [Настройка с дисплея и кнопки](./device-setup.md)
- [Использование веб-панели репитера](./web-panel.md) - [Использование веб-панели репитера](./web-panel.md)
- [Использование веб-API репитера](./api.md) - [Использование веб-API репитера](./api.md)
- [Пользовательские CLI-команды](./custom-cli.md) - [Пользовательские CLI-команды](./custom-cli.md)