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:
1 parent
26b724c0f0
commit
71f7095d17
8 files changed
+701
-73
No files matched your search
@@ -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).
|
||||
|
||||
## Сборка и релизы
|
||||
|
||||
Репозиторий живёт на <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`;
|
||||
- из документации, `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` читает значение по правильному смещению;
|
||||
|
||||
@@ -319,6 +319,9 @@ public:
|
||||
NodePrefs* getNodePrefs() {
|
||||
return &_prefs;
|
||||
}
|
||||
// Локальному мастеру настройки (UITask) нужен тот же обработчик команд, что и
|
||||
// последовательному порту: проверка значений и сохранение остаются в CommonCLI.
|
||||
CommonCLI& getCLI() { return _cli; }
|
||||
|
||||
void savePrefs() override {
|
||||
_cli.savePrefs(_fs);
|
||||
|
||||
@@ -1,17 +1,55 @@
|
||||
#include "UITask.h"
|
||||
#include "target.h"
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#if defined(ESP32)
|
||||
#include <WiFi.h>
|
||||
#endif
|
||||
#include <helpers/CommonCLI.h>
|
||||
#include <helpers/ui/MomentaryButton.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,72 @@
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/CommonCLI.h>
|
||||
|
||||
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();
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 их удобнее задавать из веб-панели.
|
||||
- Если дисплея нет, мастер не компилируется в прошивку и на поведение узла не влияет.
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user