2204 lines
79 KiB
C++
2204 lines
79 KiB
C++
#include "UITask.h"
|
|
#include <helpers/TxtDataHelpers.h>
|
|
#include "../MyMesh.h"
|
|
#include "target.h"
|
|
#include <time.h>
|
|
#ifdef ENABLE_WIFI_INTERFACE
|
|
#include <WiFi.h>
|
|
#endif
|
|
|
|
#ifndef AUTO_OFF_MILLIS
|
|
#define AUTO_OFF_MILLIS 15000 // 15 seconds
|
|
#endif
|
|
#define BOOT_SCREEN_MILLIS 3000 // 3 seconds
|
|
|
|
#ifdef PIN_STATUS_LED
|
|
#define LED_ON_MILLIS 20
|
|
#define LED_ON_MSG_MILLIS 200
|
|
#define LED_CYCLE_MILLIS 4000
|
|
#endif
|
|
|
|
#define LONG_PRESS_MILLIS 1200
|
|
|
|
#ifndef UI_RECENT_LIST_SIZE
|
|
#define UI_RECENT_LIST_SIZE 4
|
|
#endif
|
|
|
|
#if UI_HAS_JOYSTICK || UI_HAS_ROTARY_INPUT
|
|
#define PRESS_LABEL "press Enter"
|
|
#else
|
|
#define PRESS_LABEL "long press"
|
|
#endif
|
|
|
|
#include "icons.h"
|
|
#include <math.h>
|
|
|
|
// --- Ручная настройка радио (страница RADIO) ---
|
|
// Границы частоты по умолчанию — для SX126x (150..960 МГц). Для других модулей
|
|
// можно переопределить в env варианта.
|
|
#ifndef UI_RADIO_FREQ_MIN
|
|
#define UI_RADIO_FREQ_MIN 150.0f
|
|
#endif
|
|
#ifndef UI_RADIO_FREQ_MAX
|
|
#define UI_RADIO_FREQ_MAX 960.0f
|
|
#endif
|
|
// Частота правится по разрядам, как в PosadMesh: целые МГц меняются шагом 1 МГц,
|
|
// а килогерцы — по символам (сотни, десятки, единицы). Разряд 0 — МГц, 1..3 — кГц.
|
|
#define UI_RADIO_FREQ_KHZ_DIGITS 3
|
|
#define UI_RADIO_MENU_TIMEOUT_MILLIS 300000UL // 5 минут простоя — выйти из меню без сохранения
|
|
#define NOTIFY_MAIN_ROWS 7 // строк в меню уведомлений
|
|
|
|
// Строка меню: выбранная — инверсией, значение — справа вторичным цветом
|
|
static void drawMenuRow(DisplayDriver& display, int y, bool sel, const char* label, const char* value) {
|
|
if (sel) {
|
|
display.setColor(UIColor::primary_txt);
|
|
display.fillRect(0, y - 1, display.width(), 9);
|
|
display.setColor(UIColor::window_bkg);
|
|
display.drawTextLeftAlign(1, y, label);
|
|
if (value[0]) display.drawTextRightAlign(display.width() - 2, y, value);
|
|
} else {
|
|
display.setColor(UIColor::primary_txt);
|
|
display.drawTextLeftAlign(1, y, label);
|
|
if (value[0]) {
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.drawTextRightAlign(display.width() - 2, y, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
// BW перебирается по списку допустимых значений, а не шагом
|
|
static const float UI_RADIO_BW_STEPS[] = {
|
|
7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, 62.5f, 125.0f, 250.0f, 500.0f
|
|
};
|
|
#define UI_RADIO_BW_STEPS_N (sizeof(UI_RADIO_BW_STEPS) / sizeof(UI_RADIO_BW_STEPS[0]))
|
|
|
|
class SplashScreen : public UIScreen {
|
|
UITask* _task;
|
|
unsigned long dismiss_after;
|
|
char _version_info[12];
|
|
char _meshinfo_info[20];
|
|
|
|
public:
|
|
SplashScreen(UITask* task) : _task(task) {
|
|
// strip off dash and commit hash by changing dash to null terminator
|
|
// e.g: v1.2.3-abcdef -> v1.2.3
|
|
const char *ver = FIRMWARE_VERSION;
|
|
const char *dash = strchr(ver, '-');
|
|
|
|
int len = dash ? dash - ver : strlen(ver);
|
|
if (len >= sizeof(_version_info)) len = sizeof(_version_info) - 1;
|
|
memcpy(_version_info, ver, len);
|
|
_version_info[len] = 0;
|
|
|
|
// версия форка MESHINFO (суффикс -sha отбрасываем так же)
|
|
const char *mf = MESHINFO_VERSION;
|
|
const char *mf_dash = strchr(mf, '-');
|
|
int mf_len = mf_dash ? mf_dash - mf : strlen(mf);
|
|
if (mf_len > (int)sizeof(_meshinfo_info) - 10) mf_len = sizeof(_meshinfo_info) - 10;
|
|
snprintf(_meshinfo_info, sizeof(_meshinfo_info), "MESHINFO %.*s", mf_len, mf);
|
|
|
|
dismiss_after = millis() + BOOT_SCREEN_MILLIS;
|
|
}
|
|
|
|
int render(DisplayDriver& display) override {
|
|
// 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);
|
|
uint16_t websiteWidth = display.getTextWidth(website);
|
|
display.setCursor((display.width() - websiteWidth) / 2, 20);
|
|
display.print(website);
|
|
|
|
// версия MeshCore — основная версия прошивки
|
|
display.setColor(UIColor::primary_txt);
|
|
display.setTextSize(1);
|
|
display.drawTextCentered(display.width()/2, 31, _version_info);
|
|
|
|
// версия форка и его сайт (дату сборки на стартовом экране не показываем)
|
|
display.setColor(UIColor::warning_txt);
|
|
display.drawTextCentered(display.width()/2, 41, _meshinfo_info);
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.drawTextCentered(display.width()/2, 51, "meshinfo.ru");
|
|
|
|
return 1000;
|
|
}
|
|
|
|
void poll() override {
|
|
if (millis() >= dismiss_after) {
|
|
_task->gotoHomeScreen();
|
|
}
|
|
}
|
|
};
|
|
|
|
class HomeScreen : public UIScreen {
|
|
enum HomePage {
|
|
FIRST,
|
|
RECENT,
|
|
RADIO,
|
|
BLUETOOTH,
|
|
ADVERT,
|
|
#if ENV_INCLUDE_GPS == 1
|
|
GPS,
|
|
#endif
|
|
#if UI_SENSORS_PAGE == 1
|
|
SENSORS,
|
|
#endif
|
|
#if UI_DISCOVER_SCREEN
|
|
DISCOVERY,
|
|
#endif
|
|
THEME, // выбор темы дисплея (только если драйвер поддерживает темы)
|
|
POWER, // battery display, ADC correction, reboot/shutdown
|
|
Count // keep as last
|
|
};
|
|
|
|
UITask* _task;
|
|
mesh::RTCClock* _rtc;
|
|
SensorManager* _sensors;
|
|
NodePrefs* _node_prefs;
|
|
uint8_t _page;
|
|
bool _shutdown_init;
|
|
bool _shutdown_restart; // что делать после отпускания кнопки: true — reboot, false — power off
|
|
|
|
// меню Power (страница POWER)
|
|
enum PowerScreen { PWR_OFF, PWR_MAIN, PWR_ADC, PWR_RS };
|
|
enum PowerRow { PWR_BATT, PWR_ADC_ROW, PWR_RST_ROW, PWR_BACK, PWR_ROW_COUNT };
|
|
uint8_t _power_screen;
|
|
uint8_t _power_sel;
|
|
uint8_t _rs_sel;
|
|
unsigned long _power_last_activity;
|
|
uint16_t _batt_mv; // сглаженное напряжение батареи (для индикатора и страницы POWER)
|
|
AdvertPath recent[UI_RECENT_LIST_SIZE];
|
|
#if UI_DISCOVER_SCREEN
|
|
DiscoveredNode discovered[DISCOVERED_NODES_TABLE_SIZE]; // not circular, latest discovered nodes are not kept
|
|
uint32_t disc_node_req_tag = 0;
|
|
uint32_t disc_nodes_count = 0;
|
|
uint32_t discovery_req_time = 0;
|
|
bool discovery_disp_names = true; // by default display names if available (removes SNR_O)
|
|
#endif
|
|
|
|
// режим ручной настройки радио (страница RADIO)
|
|
enum RadioRow { ROW_FQ, ROW_BW, ROW_SF, ROW_CR, ROW_TX, ROW_SAVE, ROW_COUNT };
|
|
bool _radio_edit;
|
|
bool _radio_val_edit;
|
|
uint8_t _radio_sel;
|
|
uint8_t _radio_fq_pos; // активный разряд частоты: 0 — МГц, 1..3 — сотни/десятки/единицы кГц
|
|
unsigned long _radio_last_activity; // для автовыхода из меню по простою
|
|
|
|
void radioEditEnter() {
|
|
_task->clearAlert(); // висящий попап (например, от входящего сообщения) не должен перекрывать меню
|
|
_radio_edit = true;
|
|
_radio_val_edit = false;
|
|
_radio_sel = ROW_FQ;
|
|
_radio_fq_pos = 0;
|
|
_radio_last_activity = millis();
|
|
}
|
|
void radioEditLeave(bool save) {
|
|
_radio_edit = false;
|
|
_radio_val_edit = false;
|
|
if (save) {
|
|
the_mesh.savePrefs();
|
|
_task->showAlert("Saved", 1000);
|
|
}
|
|
_task->showPendingMessages(); // сообщения, пришедшие во время настройки
|
|
}
|
|
void radioRowStep(int8_t dir) { _radio_sel = (_radio_sel + ROW_COUNT + dir) % ROW_COUNT; }
|
|
void radioValueStep(int8_t dir);
|
|
void radioRowValue(uint8_t row, char* buf, size_t len);
|
|
const char* radioRowName(uint8_t row);
|
|
void renderRadioEditor(DisplayDriver& display);
|
|
|
|
// меню Power: отображение батареи, коррекция АЦП, reboot/shutdown
|
|
void powerEnter();
|
|
void powerLeave();
|
|
void powerStep(int8_t dir);
|
|
void powerAction();
|
|
void powerAdcStep(int8_t dir);
|
|
float adcBaseValue(); // текущий множитель АЦП: prefs → борд → ADC_MULTIPLIER варианта
|
|
uint16_t battMilliVolts(); // одно измерение на кадр + сглаживание
|
|
void renderPowerPage(DisplayDriver& display, uint16_t milliVolts);
|
|
void renderPowerScreen(DisplayDriver& display);
|
|
void renderPowerAdc(DisplayDriver& display);
|
|
|
|
// выбор темы дисплея (страница THEME)
|
|
bool _theme_edit;
|
|
uint8_t _theme_sel;
|
|
unsigned long _theme_last_activity;
|
|
|
|
void themeEditEnter() {
|
|
DisplayDriver* d = _task->getDisplay();
|
|
if (d == NULL || d->getThemeCount() == 0) return;
|
|
_task->clearAlert();
|
|
_theme_edit = true;
|
|
_theme_sel = d->getTheme(); // начинаем с активной темы
|
|
_theme_last_activity = millis();
|
|
}
|
|
void themeStore() { // запомнить текущую (предпросмотренную) тему в prefs
|
|
DisplayDriver* d = _task->getDisplay();
|
|
if (d == NULL) return;
|
|
const char* name = d->getThemeName(d->getTheme());
|
|
strncpy(_node_prefs->ui_theme, name, sizeof(_node_prefs->ui_theme) - 1);
|
|
_node_prefs->ui_theme[sizeof(_node_prefs->ui_theme) - 1] = 0;
|
|
the_mesh.savePrefs();
|
|
}
|
|
// вернуть тему, записанную в prefs (предпросмотр откатывается)
|
|
void themeRevert() {
|
|
DisplayDriver* d = _task->getDisplay();
|
|
if (d == NULL) return;
|
|
uint8_t idx = d->findTheme(_node_prefs->ui_theme);
|
|
d->setTheme(idx == 0xFF ? 0 : idx);
|
|
}
|
|
void themeEditLeave() {
|
|
themeRevert(); // тема сохраняется только длинным нажатием на строке темы, выход ничего не меняет
|
|
_theme_edit = false;
|
|
_task->showPendingMessages();
|
|
}
|
|
void themeRowStep(int8_t dir) {
|
|
DisplayDriver* d = _task->getDisplay();
|
|
if (d == NULL) return;
|
|
uint8_t rows = d->getThemeCount() + 1; // темы + строка выхода
|
|
_theme_sel = (_theme_sel + rows + dir) % rows;
|
|
if (_theme_sel < d->getThemeCount()) d->setTheme(_theme_sel); // только предпросмотр, в prefs не пишем
|
|
}
|
|
void renderThemePage(DisplayDriver& display);
|
|
void renderThemeEditor(DisplayDriver& display);
|
|
uint8_t pageStep(int8_t dir);
|
|
|
|
// меню уведомлений (первый экран, длинное нажатие)
|
|
enum NotifyScreen { NOTIFY_OFF, NOTIFY_MAIN, NOTIFY_CHANNELS, NOTIFY_QUIET };
|
|
uint8_t _notify_screen;
|
|
uint8_t _notify_sel;
|
|
bool _notify_val_edit; // правка часа в подменю тихих часов
|
|
unsigned long _notify_last_activity;
|
|
|
|
void notifyEnter() {
|
|
_task->clearAlert();
|
|
_notify_screen = NOTIFY_MAIN;
|
|
_notify_sel = 0;
|
|
_notify_val_edit = false;
|
|
_notify_last_activity = millis();
|
|
}
|
|
void notifyLeave(bool save) {
|
|
_notify_screen = NOTIFY_OFF;
|
|
_notify_val_edit = false;
|
|
if (save) {
|
|
the_mesh.savePrefs();
|
|
_task->showAlert("Saved", 1000);
|
|
}
|
|
_task->showPendingMessages();
|
|
}
|
|
uint8_t alertsMode() const { // 0 = звук+вибро, 1 = только звук, 2 = только вибро, 3 = выкл
|
|
bool snd = _node_prefs->buzzer_quiet == 0, vib = _node_prefs->vibe_quiet == 0;
|
|
return (uint8_t)(snd ? (vib ? 0 : 1) : (vib ? 2 : 3));
|
|
}
|
|
void alertsCycle() {
|
|
uint8_t m = (uint8_t)((alertsMode() + 1) & 3);
|
|
// 0 both, 1 sound, 2 vibe, 3 off
|
|
_node_prefs->buzzer_quiet = (m == 2 || m == 3) ? 1 : 0;
|
|
_node_prefs->vibe_quiet = (m == 1 || m == 3) ? 1 : 0;
|
|
}
|
|
uint8_t channelCount();
|
|
uint8_t channelIdxAt(uint8_t pos);
|
|
uint8_t notifyRowCount();
|
|
void notifyStep(int8_t dir);
|
|
void notifyActivate();
|
|
void notifyValueStep(int8_t dir);
|
|
void renderNotifyMenu(DisplayDriver& display);
|
|
|
|
|
|
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
|
|
// Convert millivolts to percentage
|
|
#ifndef BATT_MIN_MILLIVOLTS
|
|
#define BATT_MIN_MILLIVOLTS 3000
|
|
#endif
|
|
#ifndef BATT_MAX_MILLIVOLTS
|
|
#define BATT_MAX_MILLIVOLTS 4200
|
|
#endif
|
|
const int minMilliVolts = BATT_MIN_MILLIVOLTS;
|
|
const int maxMilliVolts = BATT_MAX_MILLIVOLTS;
|
|
int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
|
|
if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0%
|
|
if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100%
|
|
|
|
// battery icon
|
|
// что показывать рядом с батареей — напряжение, процент или ничего (страница POWER)
|
|
const uint8_t mode = _node_prefs->batt_display;
|
|
int textWidth = 0;
|
|
bool charging = false;
|
|
|
|
if (mode != BATT_DISPLAY_OFF) {
|
|
// компактная иконка в правом верхнем углу
|
|
const int iconWidth = 18;
|
|
const int iconHeight = 9;
|
|
const int iconX = display.width() - iconWidth - 3;
|
|
const int iconY = 1;
|
|
display.setColor(UIColor::title_txt);
|
|
|
|
// battery outline
|
|
display.drawRect(iconX, iconY, iconWidth, iconHeight);
|
|
|
|
// battery "cap"
|
|
display.fillRect(iconX + iconWidth, iconY + 2, 2, iconHeight - 4);
|
|
|
|
// fill the battery based on the percentage
|
|
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
|
|
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
|
|
|
|
// на зарядке слева от батареи — молния (или вилка, когда уже почти полная)
|
|
charging = board.isExternalPowered();
|
|
int shift = 0;
|
|
if (charging) {
|
|
const int BATT_FULL_PCT = 95; // точного признака «заряжено» у плат нет
|
|
const uint8_t* symbol = (batteryPercentage >= BATT_FULL_PCT) ? plug_icon : charging_icon;
|
|
display.setColor(UIColor::title_txt);
|
|
display.drawXbm(iconX - 9, iconY + 1, symbol, 8, 8);
|
|
shift = 10; // значение сдвигаем левее, чтобы не наложилось
|
|
}
|
|
|
|
char txt[10];
|
|
if (mode == BATT_DISPLAY_VOLT) {
|
|
snprintf(txt, sizeof(txt), "%.2fV", batteryMilliVolts / 1000.0f);
|
|
} else {
|
|
snprintf(txt, sizeof(txt), "%d%%", batteryPercentage);
|
|
}
|
|
textWidth = (int)display.getTextWidth(txt);
|
|
display.drawTextRightAlign(iconX - 3 - shift, iconY, txt);
|
|
}
|
|
|
|
// show muted icon if buzzer is muted (левее значения батареи)
|
|
#ifdef PIN_BUZZER
|
|
if (_task->isBuzzerQuiet()) {
|
|
const int iconX = display.width() - 18 - 3;
|
|
display.setColor(UIColor::warning_txt);
|
|
display.drawXbm(iconX - 3 - textWidth - 10 - (charging ? 10 : 0), 1, muted_icon, 8, 8);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
CayenneLPP sensors_lpp;
|
|
int sensors_nb = 0;
|
|
bool sensors_scroll = false;
|
|
int sensors_scroll_offset = 0;
|
|
int next_sensors_refresh = 0;
|
|
|
|
void refresh_sensors() {
|
|
if (millis() > next_sensors_refresh) {
|
|
sensors_lpp.reset();
|
|
sensors_nb = 0;
|
|
sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f);
|
|
sensors.querySensors(0xFF, sensors_lpp);
|
|
LPPReader reader (sensors_lpp.getBuffer(), sensors_lpp.getSize());
|
|
uint8_t channel, type;
|
|
while(reader.readHeader(channel, type)) {
|
|
reader.skipData(type);
|
|
sensors_nb ++;
|
|
}
|
|
sensors_scroll = sensors_nb > UI_RECENT_LIST_SIZE;
|
|
#if AUTO_OFF_MILLIS > 0
|
|
next_sensors_refresh = millis() + 5000; // refresh sensor values every 5 sec
|
|
#else
|
|
next_sensors_refresh = millis() + 60000; // refresh sensor values every 1 min
|
|
#endif
|
|
}
|
|
}
|
|
|
|
public:
|
|
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs)
|
|
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
|
|
_shutdown_init(false), _shutdown_restart(false),
|
|
_power_screen(PWR_OFF), _power_sel(0), _rs_sel(0), _power_last_activity(0), _batt_mv(0),
|
|
_radio_edit(false), _radio_val_edit(false), _radio_sel(0), _radio_fq_pos(0),
|
|
_radio_last_activity(0), _theme_edit(false), _theme_sel(0), _theme_last_activity(0),
|
|
_notify_screen(NOTIFY_OFF), _notify_sel(0), _notify_val_edit(false), _notify_last_activity(0),
|
|
sensors_lpp(200) { }
|
|
|
|
// в меню настроек (радио, темы, уведомления) входящие сообщения не всплывают поверх экрана
|
|
bool suppressAlerts() override { return _radio_edit || _theme_edit || _notify_screen != NOTIFY_OFF; }
|
|
|
|
#if UI_DISCOVER_SCREEN
|
|
bool sendDiscoverRequest() {
|
|
uint8_t cmd_bytes[6];
|
|
cmd_bytes[0] = CTL_TYPE_NODE_DISCOVER_REQ | 1; // DISCOVER_REQ | prefix only
|
|
cmd_bytes[1] = 0xFF; // Repeaters
|
|
the_mesh.getRNG()->random((uint8_t *) &disc_node_req_tag, 4); // generate random tag
|
|
memcpy(&cmd_bytes[2], &disc_node_req_tag, 4);
|
|
disc_nodes_count = 0;
|
|
mesh::Packet* req = the_mesh.createControlData(cmd_bytes, sizeof(cmd_bytes));
|
|
if (req) {
|
|
the_mesh.sendZeroHop(req);
|
|
discovery_req_time = millis();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void handleDiscoverResponse(const mesh::Packet* packet) {
|
|
if (disc_nodes_count < DISCOVERED_NODES_TABLE_SIZE && memcmp(&packet->payload[2], &disc_node_req_tag, 4) == 0) {
|
|
auto d = &discovered[disc_nodes_count++];
|
|
memcpy(d->pubkey_prefix, &packet->payload[6], 8);
|
|
d->type = packet->payload[0] & 0xF;
|
|
d->snr_out = ((int8_t)packet->payload[1]) / 4.0;
|
|
d->snr_in = radio_driver.getLastSNR();
|
|
ContactInfo* c = the_mesh.lookupContactByPubKey(&packet->payload[6], 8);
|
|
if (c != NULL) {
|
|
strncpy(d->name, c->name, 32);
|
|
} else {
|
|
d->name[0] = 0;
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
void poll() override {
|
|
if (_radio_edit) {
|
|
if (millis() - _radio_last_activity >= UI_RADIO_MENU_TIMEOUT_MILLIS) {
|
|
radioEditLeave(false); // 5 минут без действий: выходим без сохранения
|
|
_task->blankDisplay(); // и гасим экран
|
|
} else {
|
|
_task->keepDisplayOn(); // пока работаем — экран не гасим по таймауту
|
|
}
|
|
}
|
|
if (_theme_edit) {
|
|
if (millis() - _theme_last_activity >= UI_RADIO_MENU_TIMEOUT_MILLIS) {
|
|
themeEditLeave();
|
|
_task->blankDisplay();
|
|
} else {
|
|
_task->keepDisplayOn();
|
|
}
|
|
}
|
|
if (_notify_screen != NOTIFY_OFF) {
|
|
if (millis() - _notify_last_activity >= UI_RADIO_MENU_TIMEOUT_MILLIS) {
|
|
notifyLeave(false);
|
|
_task->blankDisplay();
|
|
} else {
|
|
_task->keepDisplayOn();
|
|
}
|
|
}
|
|
if (_power_screen != PWR_OFF) {
|
|
if (millis() - _power_last_activity >= UI_RADIO_MENU_TIMEOUT_MILLIS) {
|
|
powerLeave(); // 5 минут простоя — закрыть меню
|
|
_task->blankDisplay();
|
|
} else {
|
|
_task->keepDisplayOn();
|
|
}
|
|
}
|
|
if (_shutdown_init && !_task->isButtonPressed()) { // дождаться отпускания кнопки
|
|
_task->shutdown(_shutdown_restart);
|
|
}
|
|
}
|
|
|
|
int render(DisplayDriver& display) override {
|
|
if (_radio_edit) {
|
|
// режим настройки радио: без статус-бара (имя ноды / заряд) и точек страниц —
|
|
// всё место отдано параметрам
|
|
renderRadioEditor(display);
|
|
return 5000;
|
|
}
|
|
if (_theme_edit) {
|
|
renderThemeEditor(display);
|
|
return 5000;
|
|
}
|
|
if (_notify_screen != NOTIFY_OFF) {
|
|
renderNotifyMenu(display);
|
|
return 5000;
|
|
}
|
|
if (_power_screen != PWR_OFF) {
|
|
renderPowerScreen(display);
|
|
return 5000;
|
|
}
|
|
display.setColor(UIColor::title_bkg);
|
|
display.fillRect(0, 0, display.width(), 12);
|
|
char tmp[80];
|
|
// node name
|
|
display.setTextSize(1);
|
|
display.setColor(UIColor::title_txt);
|
|
char filtered_name[sizeof(_node_prefs->node_name)];
|
|
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
|
|
display.setCursor(0, 2);
|
|
display.print(filtered_name);
|
|
|
|
// battery voltage: одно измерение на кадр, с мягким сглаживанием —
|
|
// без него напряжение в статус-баре дрожало в последнем разряде
|
|
uint16_t batt_mv = battMilliVolts();
|
|
renderBatteryIndicator(display, batt_mv);
|
|
|
|
// curr page indicator: активная точка — акцентным цветом, остальные — вторичным.
|
|
// Раньше неактивные точки красились в title_bkg, и на тёмных палитрах
|
|
// (тёмная полоса на тёмном фоне) индикатор становился неразличим.
|
|
int y = 14;
|
|
int x = display.width() / 2 - 5 * (HomePage::Count-1);
|
|
for (uint8_t i = 0; i < HomePage::Count; i++, x += 10) {
|
|
if (i == _page) {
|
|
display.setColor(UIColor::warning_txt);
|
|
display.fillRect(x-1, y-1, 4, 4);
|
|
} else {
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.fillRect(x, y, 2, 2);
|
|
}
|
|
}
|
|
|
|
if (_page == HomePage::FIRST) {
|
|
display.setColor(UIColor::primary_txt);
|
|
|
|
// На узких экранах (128x64 — OLED и T096) раскладка оригинала не помещается:
|
|
// там счётчик и признак связи идут одной мелкой строкой, под ней часы и дата.
|
|
const bool compact = display.height() < 100;
|
|
|
|
if (compact) {
|
|
display.setTextSize(1);
|
|
sprintf(tmp, "MSG: %d", _task->getMsgCount());
|
|
display.setCursor(0, 19);
|
|
display.print(tmp);
|
|
if (_task->hasConnection()) {
|
|
display.setColor(UIColor::warning_txt);
|
|
display.drawTextRightAlign(display.width() - 1, 19, "<Linked>");
|
|
display.setColor(UIColor::primary_txt);
|
|
} else if (the_mesh.getBLEPin() != 0) { // BT pin
|
|
display.setColor(UIColor::warning_txt);
|
|
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
|
|
display.drawTextRightAlign(display.width() - 1, 19, tmp);
|
|
display.setColor(UIColor::primary_txt);
|
|
}
|
|
} else {
|
|
#ifdef ENABLE_WIFI_INTERFACE
|
|
// smaller unread message ui for wifi variants
|
|
display.setTextSize(1);
|
|
sprintf(tmp, "Unread Msg: %d", _task->getMsgCount());
|
|
display.drawTextCentered(display.width() / 2, 22, tmp);
|
|
#else
|
|
// larger unread message ui for non wifi variants
|
|
display.setTextSize(2);
|
|
sprintf(tmp, "MSG: %d", _task->getMsgCount());
|
|
display.drawTextCentered(display.width() / 2, 22, tmp);
|
|
#endif
|
|
}
|
|
|
|
#ifdef UI_SHOW_CLOCK
|
|
{
|
|
uint32_t now = _rtc->getCurrentTime();
|
|
now += (int32_t)_node_prefs->tz_offset * 3600;
|
|
DateTime dt (now);
|
|
display.setTextSize(compact ? 2 : 3);
|
|
sprintf(tmp, "%02d:%02d", dt.hour(), dt.minute());
|
|
display.drawTextCentered(display.width() / 2, compact ? 30 : 60, tmp);
|
|
display.setTextSize(1);
|
|
display.setColor(UIColor::secondary_txt);
|
|
sprintf(tmp, "%02d/%02d/%d", dt.day(), dt.month(), dt.year());
|
|
display.drawTextCentered(display.width() / 2, compact ? 46 : 80, tmp);
|
|
display.setColor(UIColor::primary_txt);
|
|
}
|
|
#endif
|
|
|
|
#ifdef ENABLE_WIFI_INTERFACE
|
|
IPAddress ip = WiFi.localIP();
|
|
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
|
display.setTextSize(1);
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.drawTextCentered(display.width() / 2, compact ? 55 : 32, tmp);
|
|
display.setColor(UIColor::primary_txt);
|
|
#endif
|
|
|
|
if (!compact) {
|
|
if (_task->hasConnection()) {
|
|
display.setColor(UIColor::warning_txt);
|
|
display.setTextSize(1);
|
|
#ifdef UI_SHOW_CLOCK
|
|
display.drawTextCentered(display.width() / 2, 110, "< Connected >");
|
|
#else
|
|
display.drawTextCentered(display.width() / 2, 43, "< Connected >");
|
|
#endif
|
|
} else if (the_mesh.getBLEPin() != 0) { // BT pin
|
|
display.setColor(UIColor::warning_txt);
|
|
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
|
|
#ifdef UI_SHOW_CLOCK
|
|
display.setTextSize(1);
|
|
display.drawTextCentered(display.width() / 2, 110, tmp);
|
|
#else
|
|
display.setTextSize(2);
|
|
display.drawTextCentered(display.width() / 2, 43, tmp);
|
|
#endif
|
|
}
|
|
}
|
|
} else if (_page == HomePage::RECENT) {
|
|
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
|
|
display.setColor(UIColor::primary_txt);
|
|
int y = 20;
|
|
for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) {
|
|
auto a = &recent[i];
|
|
if (a->name[0] == 0) continue; // empty slot
|
|
int secs = _rtc->getCurrentTime() - a->recv_timestamp;
|
|
if (secs < 60) {
|
|
sprintf(tmp, "%ds", secs);
|
|
} else if (secs < 60*60) {
|
|
sprintf(tmp, "%dm", secs / 60);
|
|
} else {
|
|
sprintf(tmp, "%dh", secs / (60*60));
|
|
}
|
|
|
|
int timestamp_width = display.getTextWidth(tmp);
|
|
int max_name_width = display.width() - timestamp_width - 1;
|
|
|
|
char filtered_recent_name[sizeof(a->name)];
|
|
display.translateUTF8ToBlocks(filtered_recent_name, a->name, sizeof(filtered_recent_name));
|
|
display.drawTextEllipsized(0, y, max_name_width, filtered_recent_name);
|
|
display.setCursor(display.width() - timestamp_width - 1, y);
|
|
display.print(tmp);
|
|
}
|
|
} else if (_page == HomePage::RADIO) {
|
|
display.setColor(UIColor::primary_txt);
|
|
display.setTextSize(1);
|
|
// freq / sf
|
|
display.setCursor(0, 20);
|
|
sprintf(tmp, "FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf);
|
|
display.print(tmp);
|
|
|
|
display.setCursor(0, 29);
|
|
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
|
|
display.print(tmp);
|
|
|
|
// tx power, noise floor
|
|
display.setCursor(0, 38);
|
|
sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm);
|
|
display.print(tmp);
|
|
display.setCursor(0, 47);
|
|
sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
|
|
display.print(tmp);
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.drawTextCentered(display.width() / 2, 56, "setup: " PRESS_LABEL);
|
|
} else if (_page == HomePage::BLUETOOTH) {
|
|
display.setColor(UIColor::corp_blue);
|
|
display.drawXbm((display.width() - 32) / 2, 18,
|
|
_task->isBluetoothEnabled() ? bluetooth_on : bluetooth_off,
|
|
32, 32);
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.setTextSize(1);
|
|
display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL);
|
|
} else if (_page == HomePage::ADVERT) {
|
|
display.setColor(UIColor::corp_blue);
|
|
display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL);
|
|
#if ENV_INCLUDE_GPS == 1
|
|
} else if (_page == HomePage::GPS) {
|
|
LocationProvider* nmea = sensors.getLocationProvider();
|
|
char buf[50];
|
|
int y = 18;
|
|
bool gps_state = _task->getGPSState();
|
|
#ifdef PIN_GPS_SWITCH
|
|
bool hw_gps_state = digitalRead(PIN_GPS_SWITCH);
|
|
if (gps_state != hw_gps_state) {
|
|
strcpy(buf, gps_state ? "gps off(hw)" : "gps off(sw)");
|
|
} else {
|
|
strcpy(buf, gps_state ? "gps on" : "gps off");
|
|
}
|
|
#else
|
|
strcpy(buf, gps_state ? "gps on" : "gps off");
|
|
#endif
|
|
display.setColor(UIColor::primary_txt);
|
|
display.drawTextLeftAlign(0, y, buf);
|
|
if (nmea == NULL) {
|
|
y = y + 12;
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.drawTextLeftAlign(0, y, "Can't access GPS");
|
|
} else {
|
|
display.setColor(UIColor::primary_txt);
|
|
strcpy(buf, nmea->isValid()?"fix":"no fix");
|
|
display.drawTextRightAlign(display.width()-1, y, buf);
|
|
y = y + 12;
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.drawTextLeftAlign(0, y, "sat");
|
|
display.setColor(UIColor::primary_txt);
|
|
sprintf(buf, "%d", nmea->satellitesCount());
|
|
display.drawTextRightAlign(display.width()-1, y, buf);
|
|
y = y + 12;
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.drawTextLeftAlign(0, y, "pos");
|
|
display.setColor(UIColor::primary_txt);
|
|
sprintf(buf, "%.4f %.4f",
|
|
nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.);
|
|
display.drawTextRightAlign(display.width()-1, y, buf);
|
|
y = y + 12;
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.drawTextLeftAlign(0, y, "alt");
|
|
display.setColor(UIColor::primary_txt);
|
|
sprintf(buf, "%.2f", nmea->getAltitude()/1000.);
|
|
display.drawTextRightAlign(display.width()-1, y, buf);
|
|
y = y + 12;
|
|
}
|
|
#endif
|
|
#if UI_SENSORS_PAGE == 1
|
|
} else if (_page == HomePage::SENSORS) {
|
|
int y = 18;
|
|
refresh_sensors();
|
|
char buf[30];
|
|
char name[30];
|
|
LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize());
|
|
|
|
for (int i = 0; i < sensors_scroll_offset; i++) {
|
|
uint8_t channel, type;
|
|
r.readHeader(channel, type);
|
|
r.skipData(type);
|
|
}
|
|
|
|
for (int i = 0; i < (sensors_scroll?UI_RECENT_LIST_SIZE:sensors_nb); i++) {
|
|
uint8_t channel, type;
|
|
if (!r.readHeader(channel, type)) { // reached end, reset
|
|
r.reset();
|
|
r.readHeader(channel, type);
|
|
}
|
|
|
|
display.setCursor(0, y);
|
|
float v;
|
|
switch (type) {
|
|
case LPP_GPS: // GPS
|
|
float lat, lon, alt;
|
|
r.readGPS(lat, lon, alt);
|
|
strcpy(name, "gps"); sprintf(buf, "%.4f %.4f", lat, lon);
|
|
break;
|
|
case LPP_VOLTAGE:
|
|
r.readVoltage(v);
|
|
strcpy(name, "voltage"); sprintf(buf, "%6.2f", v);
|
|
break;
|
|
case LPP_CURRENT:
|
|
r.readCurrent(v);
|
|
strcpy(name, "current"); sprintf(buf, "%.3f", v);
|
|
break;
|
|
case LPP_TEMPERATURE:
|
|
r.readTemperature(v);
|
|
strcpy(name, "temperature"); sprintf(buf, "%.2f", v);
|
|
break;
|
|
case LPP_RELATIVE_HUMIDITY:
|
|
r.readRelativeHumidity(v);
|
|
strcpy(name, "humidity"); sprintf(buf, "%.2f", v);
|
|
break;
|
|
case LPP_BAROMETRIC_PRESSURE:
|
|
r.readPressure(v);
|
|
strcpy(name, "pressure"); sprintf(buf, "%.2f", v);
|
|
break;
|
|
case LPP_ALTITUDE:
|
|
r.readAltitude(v);
|
|
strcpy(name, "altitude"); sprintf(buf, "%.0f", v);
|
|
break;
|
|
case LPP_POWER:
|
|
r.readPower(v);
|
|
strcpy(name, "power"); sprintf(buf, "%6.2f", v);
|
|
break;
|
|
default:
|
|
r.skipData(type);
|
|
strcpy(name, "unk"); sprintf(buf, "");
|
|
}
|
|
display.setCursor(0, y);
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.print(name);
|
|
display.setColor(UIColor::primary_txt);
|
|
display.setCursor(
|
|
display.width()-display.getTextWidth(buf)-1, y
|
|
);
|
|
display.print(buf);
|
|
y = y + 12;
|
|
}
|
|
if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb;
|
|
else sensors_scroll_offset = 0;
|
|
#endif
|
|
#if UI_DISCOVER_SCREEN
|
|
} else if (_page == HomePage::DISCOVERY) {
|
|
int count = disc_nodes_count;
|
|
display.setColor(UIColor::primary_txt);
|
|
int y = 20;
|
|
for (int i = 0; i < count; i++, y += 11) {
|
|
char name[32];
|
|
auto a = &discovered[i];
|
|
if ((a->name[0] == 0) || !discovery_disp_names) {
|
|
mesh::Utils::toHex(name, a->pubkey_prefix, 4);
|
|
} else {
|
|
strncpy(name, a->name, 32);
|
|
}
|
|
char filtered_name[sizeof(name)];
|
|
char snr_s[12];
|
|
if (strlen(name) <= 8) { // display snr_o
|
|
sprintf(snr_s, "%02.1f>%02.1f", a->snr_out, a->snr_in);
|
|
} else {
|
|
sprintf(snr_s, "%02.1f", a->snr_in);
|
|
}
|
|
int snr_width = display.getTextWidth(snr_s);
|
|
int max_name_width = display.width() - snr_width - 1;
|
|
display.translateUTF8ToBlocks(filtered_name, name, sizeof(filtered_name));
|
|
display.drawTextEllipsized(0, y, max_name_width, filtered_name);
|
|
display.setCursor(display.width() - snr_width - 1, y);
|
|
display.print(snr_s);
|
|
}
|
|
if (millis() < discovery_req_time + 5000) {
|
|
return 1000; // more frequent updates just after req
|
|
} else if (count < DISCOVERED_NODES_TABLE_SIZE -1) { // show only 5 sec after last disc
|
|
y = 10 + 11 * DISCOVERED_NODES_TABLE_SIZE;
|
|
display.drawTextCentered(display.width() / 2, y, "discover: " PRESS_LABEL);
|
|
}
|
|
#endif
|
|
} else if (_page == HomePage::THEME) {
|
|
renderThemePage(display);
|
|
} else if (_page == HomePage::POWER) {
|
|
renderPowerPage(display, batt_mv);
|
|
}
|
|
return 5000; // next render after 5000 ms
|
|
}
|
|
|
|
bool handleInput(char c) override {
|
|
if (_radio_edit) { // режим настройки радио: страницы не листаются
|
|
_radio_last_activity = millis(); // любое нажатие продлевает жизнь меню
|
|
if (c == KEY_NEXT || c == KEY_RIGHT) {
|
|
if (_radio_val_edit) radioValueStep(+1); else radioRowStep(+1);
|
|
return true;
|
|
}
|
|
if (c == KEY_PREV || c == KEY_LEFT) {
|
|
if (_radio_val_edit) radioValueStep(-1); else radioRowStep(-1);
|
|
return true;
|
|
}
|
|
if (c == KEY_ENTER) {
|
|
if (_radio_val_edit) {
|
|
// у частоты долгое нажатие перебирает разряды, на последнем — возврат к списку
|
|
if (_radio_sel == ROW_FQ && _radio_fq_pos < UI_RADIO_FREQ_KHZ_DIGITS) {
|
|
_radio_fq_pos++;
|
|
} else {
|
|
_radio_val_edit = false; // назад к списку
|
|
}
|
|
} else if (_radio_sel == ROW_SAVE) {
|
|
radioEditLeave(true); // сохранить в память и выйти
|
|
} else {
|
|
_radio_fq_pos = 0;
|
|
_radio_val_edit = true; // править значение
|
|
}
|
|
return true;
|
|
}
|
|
return true; // остальные события в этом режиме игнорируем
|
|
}
|
|
if (_theme_edit) { // меню выбора темы
|
|
_theme_last_activity = millis();
|
|
if (c == KEY_NEXT || c == KEY_RIGHT) {
|
|
themeRowStep(+1);
|
|
return true;
|
|
}
|
|
if (c == KEY_PREV || c == KEY_LEFT) {
|
|
themeRowStep(-1);
|
|
return true;
|
|
}
|
|
if (c == KEY_ENTER) {
|
|
DisplayDriver* d = _task->getDisplay();
|
|
if (d != NULL) {
|
|
if (_theme_sel >= d->getThemeCount()) {
|
|
themeEditLeave(); // строка "Exit" — выйти (сохранённая тема не меняется)
|
|
} else {
|
|
themeStore(); // длинное нажатие на теме — запомнить её в prefs
|
|
char msg[28];
|
|
snprintf(msg, sizeof(msg), "theme %s OK", d->getThemeName(d->getTheme()));
|
|
_task->showAlert(msg, 1200);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
return true;
|
|
}
|
|
if (_notify_screen != NOTIFY_OFF) { // меню уведомлений
|
|
_notify_last_activity = millis();
|
|
if (c == KEY_NEXT || c == KEY_RIGHT) {
|
|
if (_notify_val_edit) notifyValueStep(+1); else notifyStep(+1);
|
|
return true;
|
|
}
|
|
if (c == KEY_PREV || c == KEY_LEFT) {
|
|
if (_notify_val_edit) notifyValueStep(-1); else notifyStep(-1);
|
|
return true;
|
|
}
|
|
if (c == KEY_ENTER) {
|
|
notifyActivate();
|
|
return true;
|
|
}
|
|
return true;
|
|
}
|
|
if (_power_screen != PWR_OFF) { // меню Power: страницы не листаются
|
|
_power_last_activity = millis();
|
|
if (c == KEY_NEXT || c == KEY_RIGHT) {
|
|
powerStep(+1);
|
|
return true;
|
|
}
|
|
if (c == KEY_PREV || c == KEY_LEFT) {
|
|
powerStep(-1);
|
|
return true;
|
|
}
|
|
if (c == KEY_ENTER) {
|
|
powerAction();
|
|
return true;
|
|
}
|
|
return true;
|
|
}
|
|
if (c == KEY_LEFT || c == KEY_PREV) {
|
|
_page = pageStep(-1);
|
|
return true;
|
|
}
|
|
if (c == KEY_NEXT || c == KEY_RIGHT) {
|
|
_page = pageStep(+1);
|
|
if (_page == HomePage::RECENT) {
|
|
_task->showAlert("Recent adverts", 800);
|
|
}
|
|
#if UI_DISCOVER_SCREEN
|
|
if (_page == HomePage::DISCOVERY) {
|
|
_task->showAlert("Repeater disc", 800);
|
|
}
|
|
#endif
|
|
return true;
|
|
}
|
|
if (c == KEY_ENTER && _page == HomePage::RADIO) {
|
|
radioEditEnter();
|
|
// предупреждение: остальные узлы сети должны иметь те же параметры
|
|
_task->showAlert("Nodes must match", 1500);
|
|
return true;
|
|
}
|
|
if (c == KEY_ENTER && _page == HomePage::FIRST) {
|
|
notifyEnter(); // настройки входящих сообщений и уведомлений
|
|
return true;
|
|
}
|
|
if (c == KEY_ENTER && _page == HomePage::THEME) {
|
|
themeEditEnter();
|
|
return true;
|
|
}
|
|
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
|
|
if (_task->isBluetoothEnabled()) { // toggle Bluetooth on/off
|
|
_task->disableBluetooth();
|
|
} else {
|
|
_task->enableBluetooth();
|
|
}
|
|
return true;
|
|
}
|
|
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
|
|
_task->notify(UIEventType::ack);
|
|
if (the_mesh.advert()) {
|
|
_task->showAlert("Advert sent!", 1000);
|
|
} else {
|
|
_task->showAlert("Advert failed..", 1000);
|
|
}
|
|
return true;
|
|
}
|
|
#if ENV_INCLUDE_GPS == 1
|
|
if (c == KEY_ENTER && _page == HomePage::GPS) {
|
|
_task->toggleGPS();
|
|
return true;
|
|
}
|
|
#endif
|
|
#if UI_DISCOVER_SCREEN
|
|
if (c == KEY_ENTER && _page == HomePage::DISCOVERY) {
|
|
if (millis() > discovery_req_time + 5000) { // rate limiter
|
|
sendDiscoverRequest();
|
|
}
|
|
return true;
|
|
}
|
|
if (c == KEY_SELECT && _page == HomePage::DISCOVERY) {
|
|
discovery_disp_names = !discovery_disp_names;
|
|
return true;
|
|
}
|
|
#endif
|
|
#if UI_SENSORS_PAGE == 1
|
|
if (c == KEY_ENTER && _page == HomePage::SENSORS) {
|
|
_task->toggleGPS();
|
|
next_sensors_refresh=0;
|
|
return true;
|
|
}
|
|
#endif
|
|
if (c == KEY_ENTER && _page == HomePage::POWER) {
|
|
powerEnter(); // внутри меню Power: батарея, коррекция АЦП, reboot/shutdown
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
};
|
|
|
|
void HomeScreen::radioValueStep(int8_t dir) {
|
|
switch (_radio_sel) {
|
|
case ROW_FQ: {
|
|
// разрядное редактирование: целые МГц (±1 МГц), килогерцы — по символам
|
|
int32_t khz = (int32_t)(_node_prefs->freq * 1000.0f + 0.5f);
|
|
if (_radio_fq_pos == 0) {
|
|
khz += dir * 1000;
|
|
} else {
|
|
int step = 1;
|
|
for (uint8_t i = _radio_fq_pos; i < UI_RADIO_FREQ_KHZ_DIGITS; i++) step *= 10; // 100, 10, 1
|
|
int digit = (khz / step) % 10;
|
|
khz += ((digit + dir + 10) % 10 - digit) * step; // по кругу: 9 + 1 -> 0, 0 - 1 -> 9
|
|
}
|
|
int32_t lo = (int32_t)(UI_RADIO_FREQ_MIN * 1000.0f), hi = (int32_t)(UI_RADIO_FREQ_MAX * 1000.0f);
|
|
if (khz < lo) khz = lo;
|
|
if (khz > hi) khz = hi;
|
|
if (!the_mesh.setRadioParams((float)khz / 1000.0f, _node_prefs->bw, _node_prefs->sf, _node_prefs->cr)) {
|
|
_task->showAlert("Freq not allowed", 1200);
|
|
}
|
|
break;
|
|
}
|
|
case ROW_BW: {
|
|
// ищем ближайшее допустимое значение; если текущее не из списка —
|
|
// первый шаг приводит к ближайшему, дальше идём по списку
|
|
int idx = 0;
|
|
for (int i = 1; i < (int)UI_RADIO_BW_STEPS_N; i++) {
|
|
if (fabsf(UI_RADIO_BW_STEPS[i] - _node_prefs->bw) < fabsf(UI_RADIO_BW_STEPS[idx] - _node_prefs->bw)) idx = i;
|
|
}
|
|
int next = idx;
|
|
if (fabsf(UI_RADIO_BW_STEPS[idx] - _node_prefs->bw) < 0.01f) {
|
|
next = idx + dir;
|
|
if (next < 0) next = 0;
|
|
if (next >= (int)UI_RADIO_BW_STEPS_N) next = (int)UI_RADIO_BW_STEPS_N - 1;
|
|
}
|
|
the_mesh.setRadioParams(_node_prefs->freq, UI_RADIO_BW_STEPS[next], _node_prefs->sf, _node_prefs->cr);
|
|
break;
|
|
}
|
|
case ROW_SF: {
|
|
int v = (int)_node_prefs->sf + dir;
|
|
if (v < 5) v = 5;
|
|
if (v > 12) v = 12;
|
|
the_mesh.setRadioParams(_node_prefs->freq, _node_prefs->bw, (uint8_t)v, _node_prefs->cr);
|
|
break;
|
|
}
|
|
case ROW_CR: {
|
|
int v = (int)_node_prefs->cr + dir;
|
|
if (v < 5) v = 5;
|
|
if (v > 8) v = 8;
|
|
the_mesh.setRadioParams(_node_prefs->freq, _node_prefs->bw, _node_prefs->sf, (uint8_t)v);
|
|
break;
|
|
}
|
|
case ROW_TX: {
|
|
the_mesh.setTxPowerDbm((int8_t)((int)_node_prefs->tx_power_dbm + dir));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
void HomeScreen::radioRowValue(uint8_t row, char* buf, size_t len) {
|
|
switch (row) {
|
|
case ROW_FQ: snprintf(buf, len, "%.3f MHz", _node_prefs->freq); break;
|
|
case ROW_BW: snprintf(buf, len, "%.2f kHz", _node_prefs->bw); break;
|
|
case ROW_SF: snprintf(buf, len, "%u", (unsigned)_node_prefs->sf); break;
|
|
case ROW_CR: snprintf(buf, len, "%u", (unsigned)_node_prefs->cr); break;
|
|
case ROW_TX: snprintf(buf, len, "%d dBm", (int)_node_prefs->tx_power_dbm); break;
|
|
default: buf[0] = 0; break;
|
|
}
|
|
}
|
|
|
|
const char* HomeScreen::radioRowName(uint8_t row) {
|
|
switch (row) {
|
|
case ROW_FQ: return "FQ";
|
|
case ROW_BW: return "BW";
|
|
case ROW_SF: return "SF";
|
|
case ROW_CR: return "CR";
|
|
case ROW_TX: return "TX";
|
|
default: return "Save & exit";
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- меню Power
|
|
|
|
static const char* battDisplayName(uint8_t mode) {
|
|
if (mode == BATT_DISPLAY_VOLT) return "Volt";
|
|
if (mode == BATT_DISPLAY_PERC) return "Perc";
|
|
return "Off";
|
|
}
|
|
|
|
void HomeScreen::powerEnter() {
|
|
_task->clearAlert();
|
|
_power_screen = PWR_MAIN;
|
|
_power_sel = PWR_BATT;
|
|
_rs_sel = 0;
|
|
_power_last_activity = millis();
|
|
}
|
|
|
|
void HomeScreen::powerLeave() {
|
|
_power_screen = PWR_OFF;
|
|
_task->showPendingMessages(); // сообщения, пришедшие пока было открыто меню
|
|
}
|
|
|
|
void HomeScreen::powerStep(int8_t dir) {
|
|
if (_power_screen == PWR_ADC) { powerAdcStep(dir); return; } // ±0.01
|
|
if (_power_screen == PWR_RS) {
|
|
_rs_sel = (uint8_t)((_rs_sel + 3 + dir) % 3);
|
|
return;
|
|
}
|
|
_power_sel = (uint8_t)((_power_sel + PWR_ROW_COUNT + dir) % PWR_ROW_COUNT);
|
|
}
|
|
|
|
// длинное нажатие: у Battery переключает значение, у остальных — вход/выход
|
|
void HomeScreen::powerAction() {
|
|
if (_power_screen == PWR_ADC) { // из правки АЦП — назад в основное меню
|
|
_power_screen = PWR_MAIN;
|
|
return;
|
|
}
|
|
if (_power_screen == PWR_RS) {
|
|
if (_rs_sel == 0) { _shutdown_restart = true; _shutdown_init = true; } // reboot
|
|
else if (_rs_sel == 1) { _shutdown_restart = false; _shutdown_init = true; } // shutdown
|
|
else { _power_screen = PWR_MAIN; } // back
|
|
return;
|
|
}
|
|
switch (_power_sel) {
|
|
case PWR_BATT:
|
|
_node_prefs->batt_display = (uint8_t)((_node_prefs->batt_display + 1) % 3);
|
|
the_mesh.savePrefs(); // применяется сразу и сохраняется
|
|
_task->showAlert(battDisplayName(_node_prefs->batt_display), 800);
|
|
break;
|
|
case PWR_ADC_ROW: _power_screen = PWR_ADC; break;
|
|
case PWR_RST_ROW: _power_screen = PWR_RS; _rs_sel = 0; break;
|
|
default: powerLeave(); break; // Back
|
|
}
|
|
}
|
|
|
|
void HomeScreen::powerAdcStep(int8_t dir) {
|
|
// считаем от того значения, которое реально работает сейчас, а не от нуля
|
|
float base = adcBaseValue();
|
|
if (base <= 0.0f) base = 1.0f; // плата без множителя — считаем от 1.00
|
|
float v = base + (float)dir * 0.01f;
|
|
if (v < 0.0f) v = 0.0f;
|
|
if (v > 10.0f) v = 10.0f;
|
|
v = roundf(v * 100.0f) / 100.0f; // без накопления погрешности float
|
|
if (!_task->setAdcMultiplier(v)) {
|
|
_task->showAlert("ADC not supported", 1200); // плата не умеет корректировать — не сохраняем
|
|
return;
|
|
}
|
|
_node_prefs->adc_multiplier = v;
|
|
the_mesh.savePrefs();
|
|
}
|
|
|
|
// Множитель АЦП, который действует сейчас: из prefs, иначе из платы,
|
|
// иначе — базовый множитель варианта (ADC_MULTIPLIER в variant.h)
|
|
float HomeScreen::adcBaseValue() {
|
|
if (_node_prefs->adc_multiplier > 0.0f) return _node_prefs->adc_multiplier;
|
|
float v = _task->getAdcMultiplier();
|
|
if (v > 0.0f) return v;
|
|
#ifdef ADC_MULTIPLIER
|
|
return (float)ADC_MULTIPLIER;
|
|
#else
|
|
return 0.0f;
|
|
#endif
|
|
}
|
|
|
|
// Напряжение батареи: измеряем один раз за кадр и сглаживаем, иначе последний
|
|
// разряд на экране дрожит (ADC шумит, а индикатор и страница POWER читали его отдельно)
|
|
uint16_t HomeScreen::battMilliVolts() {
|
|
uint16_t raw = _task->getBattMilliVolts();
|
|
if (raw == 0) return _batt_mv;
|
|
_batt_mv = (_batt_mv == 0) ? raw : (uint16_t)(((uint32_t)_batt_mv + raw) / 2);
|
|
return _batt_mv;
|
|
}
|
|
|
|
void HomeScreen::renderPowerPage(DisplayDriver& display, uint16_t mv) {
|
|
char tmp[24];
|
|
int pct = ((int)mv - BATT_MIN_MILLIVOLTS) * 100 / (BATT_MAX_MILLIVOLTS - BATT_MIN_MILLIVOLTS);
|
|
if (pct < 0) pct = 0;
|
|
if (pct > 100) pct = 100;
|
|
|
|
// Крупная иконка батареи, справа — заряд и напряжение.
|
|
// Сверху 12 px занимает статус-бар, под ним (13..17) — точки страниц,
|
|
// поэтому группа начинается с y=19, а не вплотную к индикатору.
|
|
// Группу привязываем к центру экрана: на TFT 240x320 фиксированные края разъезжались.
|
|
const int half = display.width() / 2;
|
|
const int iconX = half - 60, iconY = 19, iconW = 44, iconH = 22;
|
|
display.setColor(UIColor::primary_txt);
|
|
display.drawRect(iconX, iconY, iconW, iconH);
|
|
display.fillRect(iconX + iconW, iconY + 6, 3, iconH - 12); // "пипка"
|
|
display.setColor(pct <= 15 ? UIColor::warning_txt : UIColor::primary_txt);
|
|
display.fillRect(iconX + 3, iconY + 3, (pct * (iconW - 6)) / 100, iconH - 6);
|
|
|
|
display.setColor(UIColor::warning_txt);
|
|
display.setTextSize(2);
|
|
snprintf(tmp, sizeof(tmp), "%d%%", pct);
|
|
display.drawTextRightAlign(half + 60, iconY + 2, tmp);
|
|
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.setTextSize(1);
|
|
if (mv > 0) snprintf(tmp, sizeof(tmp), "%.2f V", mv / 1000.0f);
|
|
else strcpy(tmp, "-- V");
|
|
display.drawTextRightAlign(half + 60, iconY + iconH + 1, tmp);
|
|
|
|
display.setColor(UIColor::primary_txt);
|
|
display.drawTextCentered(display.width() / 2, 64 - 11, "power: " PRESS_LABEL);
|
|
}
|
|
|
|
void HomeScreen::renderPowerScreen(DisplayDriver& display) {
|
|
if (_power_screen == PWR_ADC) {
|
|
renderPowerAdc(display);
|
|
return;
|
|
}
|
|
display.setTextSize(1);
|
|
if (_power_screen == PWR_RS) {
|
|
drawMenuRow(display, 2, _rs_sel == 0, "Reboot", "");
|
|
drawMenuRow(display, 12, _rs_sel == 1, "Shutdown", "");
|
|
drawMenuRow(display, 22, _rs_sel == 2, "Back", "");
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.drawTextCentered(display.width() / 2, 64 - 11, "hold=run clk=next");
|
|
return;
|
|
}
|
|
char val[16];
|
|
float adc = adcBaseValue();
|
|
if (adc > 0.0f) snprintf(val, sizeof(val), "%.2f", adc);
|
|
else strcpy(val, "auto");
|
|
drawMenuRow(display, 2, _power_sel == PWR_BATT, "Battery", battDisplayName(_node_prefs->batt_display));
|
|
drawMenuRow(display, 12, _power_sel == PWR_ADC_ROW, "ADC corr", val);
|
|
drawMenuRow(display, 22, _power_sel == PWR_RST_ROW, "Reboot/Shut", ">");
|
|
drawMenuRow(display, 32, _power_sel == PWR_BACK, "Back", "");
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.drawTextCentered(display.width() / 2, 64 - 11, "hold=change clk=next");
|
|
}
|
|
|
|
void HomeScreen::renderPowerAdc(DisplayDriver& display) {
|
|
char buf[16];
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.setTextSize(1);
|
|
display.drawTextLeftAlign(0, 2, "ADC +0.01/-0.01");
|
|
float adc = adcBaseValue();
|
|
if (adc > 0.0f) snprintf(buf, sizeof(buf), "%.2f", adc);
|
|
else strcpy(buf, "auto");
|
|
display.setColor(UIColor::warning_txt);
|
|
display.setTextSize(2);
|
|
display.drawTextCentered(display.width() / 2, 20, buf);
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.setTextSize(1);
|
|
display.drawTextCentered(display.width() / 2, 64 - 11, "hold=back");
|
|
}
|
|
|
|
void HomeScreen::renderRadioEditor(DisplayDriver& display) {
|
|
char buf[24];
|
|
char head[32];
|
|
|
|
if (_radio_val_edit) { // фаза правки значения
|
|
if (_radio_sel == ROW_FQ) {
|
|
// частота правится по разрядам: активный разряд инвертируем, чтобы было видно,
|
|
// что именно меняется (целые МГц или один из разрядов кГц)
|
|
int32_t khz = (int32_t)(_node_prefs->freq * 1000.0f + 0.5f);
|
|
int mhz = khz / 1000, rem = khz % 1000;
|
|
snprintf(buf, sizeof(buf), "%d.%03d", mhz, rem);
|
|
|
|
snprintf(head, sizeof(head), "FQ %.0f-%.0f MHz", UI_RADIO_FREQ_MIN, UI_RADIO_FREQ_MAX);
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.setTextSize(1);
|
|
display.drawTextLeftAlign(0, 2, head);
|
|
|
|
// Позиции символов считаем по реальным ширинам: у пропорциональных шрифтов
|
|
// (TFT_eSPI на T096) точка уже цифры, из-за фиксированного шага рамка уезжала.
|
|
char mhz_txt[8];
|
|
snprintf(mhz_txt, sizeof(mhz_txt), "%d", mhz);
|
|
int sel_len = _radio_fq_pos == 0 ? (int)strlen(mhz_txt) : 1;
|
|
int sel_start = _radio_fq_pos == 0 ? 0 : (int)strlen(mhz_txt) + _radio_fq_pos; // +1 — точка
|
|
const int buf_len = (int)strlen(buf);
|
|
|
|
display.setTextSize(2);
|
|
int total = 0;
|
|
for (int i = 0; i < buf_len; i++) {
|
|
char one[2] = { buf[i], 0 };
|
|
total += (int)display.getTextWidth(one);
|
|
}
|
|
int cx = (display.width() - total) / 2;
|
|
const int y = 20;
|
|
for (int i = 0; i < buf_len; i++) {
|
|
char one[2] = { buf[i], 0 };
|
|
int cw = (int)display.getTextWidth(one);
|
|
bool mark = (i >= sel_start && i < sel_start + sel_len);
|
|
if (mark) {
|
|
display.setColor(UIColor::primary_txt);
|
|
display.fillRect(cx - 1, y - 2, cw + 2, 20);
|
|
display.setColor(UIColor::window_bkg);
|
|
} else {
|
|
display.setColor(UIColor::warning_txt);
|
|
}
|
|
display.drawTextLeftAlign(cx, y, one);
|
|
cx += cw;
|
|
}
|
|
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.setTextSize(1);
|
|
display.drawTextCentered(display.width() / 2, 64 - 10, "clk+ dbl- hold=next");
|
|
return;
|
|
}
|
|
|
|
switch (_radio_sel) {
|
|
case ROW_BW: strcpy(head, "BW 7.8-500 kHz"); break;
|
|
case ROW_SF: strcpy(head, "SF 5-12"); break;
|
|
case ROW_CR: strcpy(head, "CR 5-8"); break;
|
|
default: snprintf(head, sizeof(head), "TX -9-%d dBm", MAX_LORA_TX_POWER); break;
|
|
}
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.setTextSize(1);
|
|
display.drawTextLeftAlign(0, 2, head);
|
|
|
|
radioRowValue(_radio_sel, buf, sizeof(buf));
|
|
display.setColor(UIColor::warning_txt);
|
|
display.setTextSize(2);
|
|
display.drawTextCentered(display.width() / 2, 20, buf);
|
|
|
|
display.setTextSize(1);
|
|
display.drawTextCentered(display.width() / 2, 64 - 10, "click+ dbl- hold=back");
|
|
return;
|
|
}
|
|
|
|
// фаза выбора: выбранная строка инвертирована
|
|
display.setTextSize(1);
|
|
int y = 2;
|
|
for (uint8_t i = 0; i < ROW_COUNT; i++, y += 10) {
|
|
radioRowValue(i, buf, sizeof(buf));
|
|
if (i == _radio_sel) {
|
|
display.setColor(UIColor::primary_txt);
|
|
display.fillRect(0, y - 1, display.width(), 10);
|
|
display.setColor(UIColor::window_bkg);
|
|
display.drawTextLeftAlign(1, y, radioRowName(i));
|
|
if (buf[0]) display.drawTextRightAlign(display.width() - 2, y, buf);
|
|
} else {
|
|
display.setColor(UIColor::primary_txt);
|
|
display.drawTextLeftAlign(1, y, radioRowName(i));
|
|
if (buf[0]) {
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.drawTextRightAlign(display.width() - 2, y, buf);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
uint8_t HomeScreen::pageStep(int8_t dir) {
|
|
uint8_t p = _page;
|
|
for (uint8_t i = 0; i < HomePage::Count; i++) {
|
|
p = (p + HomePage::Count + dir) % HomePage::Count;
|
|
// страница тем есть только у драйверов, которые поддерживают палитры
|
|
if (p != HomePage::THEME || _task->displayHasThemes()) return p;
|
|
}
|
|
return _page;
|
|
}
|
|
|
|
void HomeScreen::renderThemePage(DisplayDriver& display) {
|
|
char tmp[32];
|
|
uint8_t n = display.getThemeCount();
|
|
display.setTextSize(1);
|
|
display.setColor(UIColor::primary_txt);
|
|
snprintf(tmp, sizeof(tmp), "Theme: %s", n > 0 ? display.getThemeName(display.getTheme()) : "-");
|
|
display.setCursor(0, 22);
|
|
display.print(tmp);
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.drawTextCentered(display.width() / 2, 56, "setup: " PRESS_LABEL);
|
|
}
|
|
|
|
void HomeScreen::renderThemeEditor(DisplayDriver& display) {
|
|
uint8_t n = display.getThemeCount();
|
|
uint8_t rows = n + 1; // темы + строка выхода
|
|
const uint8_t VISIBLE = 5; // 5 строк по 10 px + подсказка внизу
|
|
uint8_t first = _theme_sel >= VISIBLE ? _theme_sel - VISIBLE + 1 : 0;
|
|
|
|
display.setTextSize(1);
|
|
int y = 2;
|
|
for (uint8_t r = first; r < rows && y <= 42; r++, y += 10) {
|
|
const char* label = (r < n) ? display.getThemeName(r) : "Exit";
|
|
if (r == _theme_sel) {
|
|
display.setColor(UIColor::primary_txt); // выбранная строка — инверсия
|
|
display.fillRect(0, y - 1, display.width(), 10);
|
|
display.setColor(UIColor::window_bkg);
|
|
} else {
|
|
display.setColor(UIColor::primary_txt);
|
|
}
|
|
display.drawTextLeftAlign(1, y, label);
|
|
|
|
if (r < n) {
|
|
// образцы цветов темы: полоса заголовка, заливка попапа, акцент
|
|
int sx = display.width() - 32;
|
|
display.setColor(display.getThemeColor(r, DisplayDriver::THEME_COLOR_TITLE));
|
|
display.fillRect(sx, y, 12, 8);
|
|
display.setColor(display.getThemeColor(r, DisplayDriver::THEME_COLOR_POPUP));
|
|
display.fillRect(sx + 13, y, 8, 8);
|
|
display.setColor(display.getThemeColor(r, DisplayDriver::THEME_COLOR_ACCENT));
|
|
display.fillRect(sx + 22, y, 10, 8);
|
|
}
|
|
}
|
|
// без длинного нажатия тема не сохраняется — выход вернёт прежнюю
|
|
display.setColor(UIColor::secondary_txt);
|
|
display.drawTextCentered(display.width() / 2, 64 - 11, "hold=save clk=next");
|
|
}
|
|
|
|
uint8_t HomeScreen::channelCount() {
|
|
uint8_t n = 0;
|
|
ChannelDetails d;
|
|
for (uint8_t i = 0; i < NOTIFY_MAX_CHANNELS; i++) {
|
|
if (the_mesh.getChannel(i, d) && d.name[0] != 0) n++;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
uint8_t HomeScreen::channelIdxAt(uint8_t pos) {
|
|
uint8_t n = 0;
|
|
ChannelDetails d;
|
|
for (uint8_t i = 0; i < NOTIFY_MAX_CHANNELS; i++) {
|
|
if (the_mesh.getChannel(i, d) && d.name[0] != 0) {
|
|
if (n == pos) return i;
|
|
n++;
|
|
}
|
|
}
|
|
return 0xFF;
|
|
}
|
|
|
|
uint8_t HomeScreen::notifyRowCount() {
|
|
switch (_notify_screen) {
|
|
case NOTIFY_QUIET: return 5; // Quiet hours / From / To / Timezone / Back
|
|
case NOTIFY_CHANNELS: return (uint8_t)(channelCount() + 1); // каналы + Back
|
|
default: return NOTIFY_MAIN_ROWS;
|
|
}
|
|
}
|
|
|
|
void HomeScreen::notifyStep(int8_t dir) {
|
|
uint8_t rows = notifyRowCount();
|
|
if (rows == 0) return;
|
|
_notify_sel = (uint8_t)((_notify_sel + rows + dir) % rows);
|
|
}
|
|
|
|
void HomeScreen::notifyValueStep(int8_t dir) { // правка часа тишины и часового пояса
|
|
if (_notify_screen != NOTIFY_QUIET) return;
|
|
if (_notify_sel == 1 || _notify_sel == 2) {
|
|
int v = (int)(_notify_sel == 1 ? _node_prefs->quiet_from : _node_prefs->quiet_to) + dir;
|
|
v %= 24;
|
|
if (v < 0) v += 24;
|
|
if (_notify_sel == 1) _node_prefs->quiet_from = (uint8_t)v;
|
|
else _node_prefs->quiet_to = (uint8_t)v;
|
|
} else if (_notify_sel == 3) { // timezone, -12..+14
|
|
int v = (int)_node_prefs->tz_offset + dir;
|
|
if (v < -12) v = -12;
|
|
if (v > 14) v = 14;
|
|
_node_prefs->tz_offset = (int8_t)v;
|
|
}
|
|
}
|
|
|
|
void HomeScreen::notifyActivate() { // длинное нажатие
|
|
if (_notify_val_edit) {
|
|
_notify_val_edit = false; // закончили правку часа
|
|
return;
|
|
}
|
|
if (_notify_screen == NOTIFY_MAIN) {
|
|
switch (_notify_sel) {
|
|
case 0: // Unread — открыть непрочитанные
|
|
notifyLeave(false);
|
|
_task->showMessages();
|
|
return;
|
|
case 1: _node_prefs->msg_popup = _node_prefs->msg_popup ? 0 : 1; return;
|
|
case 2: _node_prefs->msg_wake_display = _node_prefs->msg_wake_display ? 0 : 1; return;
|
|
case 3: alertsCycle(); return;
|
|
case 4: _notify_screen = NOTIFY_CHANNELS; _notify_sel = 0; return;
|
|
case 5: _notify_screen = NOTIFY_QUIET; _notify_sel = 0; return;
|
|
default: notifyLeave(true); return; // Save & exit
|
|
}
|
|
}
|
|
if (_notify_screen == NOTIFY_QUIET) {
|
|
switch (_notify_sel) {
|
|
case 0: _node_prefs->quiet_en = _node_prefs->quiet_en ? 0 : 1; return;
|
|
case 1:
|
|
case 2:
|
|
case 3: _notify_val_edit = true; return; // править час / часовой пояс
|
|
default: _notify_screen = NOTIFY_MAIN; _notify_sel = 5; return; // Back
|
|
}
|
|
}
|
|
if (_notify_screen == NOTIFY_CHANNELS) {
|
|
if (_notify_sel >= channelCount()) { // Back
|
|
_notify_screen = NOTIFY_MAIN;
|
|
_notify_sel = 4;
|
|
return;
|
|
}
|
|
uint8_t idx = channelIdxAt(_notify_sel);
|
|
if (idx != 0xFF) {
|
|
uint8_t m = (uint8_t)((_node_prefs->getChannelMode(idx) + 1) % 3);
|
|
_node_prefs->setChannelMode(idx, m); // POPUP -> SILENT -> OFF
|
|
}
|
|
}
|
|
}
|
|
|
|
void HomeScreen::renderNotifyMenu(DisplayDriver& display) {
|
|
char buf[24];
|
|
char val[16];
|
|
display.setTextSize(1);
|
|
|
|
if (_notify_screen == NOTIFY_QUIET) {
|
|
static const char* names[5] = { "Quiet hours", "From", "To", "Timezone", "Back" };
|
|
int y = 2;
|
|
for (uint8_t r = 0; r < 5; r++, y += 10) {
|
|
val[0] = 0;
|
|
if (r == 0) strcpy(val, _node_prefs->quiet_en ? "on" : "off");
|
|
else if (r == 1) snprintf(val, sizeof(val), "%02d:00", _node_prefs->quiet_from);
|
|
else if (r == 2) snprintf(val, sizeof(val), "%02d:00", _node_prefs->quiet_to);
|
|
else if (r == 3) snprintf(val, sizeof(val), "%+03d", (int)_node_prefs->tz_offset);
|
|
drawMenuRow(display, y, r == _notify_sel, names[r], val);
|
|
}
|
|
if (_notify_val_edit) {
|
|
display.setColor(UIColor::warning_txt);
|
|
display.drawTextCentered(display.width() / 2, 64 - 10, "click+ dbl- hold=back");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (_notify_screen == NOTIFY_CHANNELS) {
|
|
uint8_t n = channelCount();
|
|
uint8_t rows = (uint8_t)(n + 1);
|
|
const uint8_t VISIBLE = 6;
|
|
uint8_t first = _notify_sel >= VISIBLE ? (uint8_t)(_notify_sel - VISIBLE + 1) : 0;
|
|
static const char* mode_names[3] = { "popup", "silent", "off" };
|
|
int y = 2;
|
|
for (uint8_t r = first; r < rows && y <= 52; r++, y += 10) {
|
|
if (r < n) {
|
|
uint8_t idx = channelIdxAt(r);
|
|
ChannelDetails cd;
|
|
if (the_mesh.getChannel(idx, cd)) {
|
|
strncpy(buf, cd.name, sizeof(buf) - 1);
|
|
buf[sizeof(buf) - 1] = 0;
|
|
} else {
|
|
strcpy(buf, "?");
|
|
}
|
|
strcpy(val, mode_names[_node_prefs->getChannelMode(idx) % 3]);
|
|
} else {
|
|
strcpy(buf, "Back");
|
|
val[0] = 0;
|
|
}
|
|
drawMenuRow(display, y, r == _notify_sel, buf, val);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// NOTIFY_MAIN
|
|
static const char* labels[NOTIFY_MAIN_ROWS] = {
|
|
"Unread", "Popups", "Wake screen", "Alerts", "Channels", "Quiet hours", "Save & exit"
|
|
};
|
|
static const char* alert_names[4] = { "sound+vibe", "sound", "vibe", "off" };
|
|
int y = 1;
|
|
for (uint8_t r = 0; r < NOTIFY_MAIN_ROWS; r++, y += 9) {
|
|
val[0] = 0;
|
|
switch (r) {
|
|
case 0: snprintf(val, sizeof(val), "%d", _task->getMsgCount()); break;
|
|
case 1: strcpy(val, _node_prefs->msg_popup ? "on" : "off"); break;
|
|
case 2: strcpy(val, _node_prefs->msg_wake_display ? "on" : "off"); break;
|
|
case 3: strcpy(val, alert_names[alertsMode()]); break;
|
|
case 4: snprintf(val, sizeof(val), "%d >", channelCount()); break;
|
|
case 5:
|
|
if (_node_prefs->quiet_en) snprintf(val, sizeof(val), "%02d-%02d", _node_prefs->quiet_from, _node_prefs->quiet_to);
|
|
else strcpy(val, "off");
|
|
break;
|
|
default: break;
|
|
}
|
|
drawMenuRow(display, y, r == _notify_sel, labels[r], val);
|
|
}
|
|
}
|
|
|
|
#ifndef UI_MSG_PREVIEW_SIZE
|
|
#define UI_MSG_PREVIEW_SIZE 78
|
|
#endif
|
|
|
|
class MsgPreviewScreen : public UIScreen {
|
|
UITask* _task;
|
|
mesh::RTCClock* _rtc;
|
|
|
|
struct MsgEntry {
|
|
uint32_t timestamp;
|
|
char origin[62];
|
|
char msg[UI_MSG_PREVIEW_SIZE];
|
|
};
|
|
#define MAX_UNREAD_MSGS 32
|
|
int num_unread;
|
|
int head = MAX_UNREAD_MSGS - 1; // index of latest unread message
|
|
MsgEntry unread[MAX_UNREAD_MSGS];
|
|
|
|
public:
|
|
MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc) { num_unread = 0; }
|
|
|
|
void addPreview(uint8_t path_len, const char* from_name, const char* msg) {
|
|
head = (head + 1) % MAX_UNREAD_MSGS;
|
|
if (num_unread < MAX_UNREAD_MSGS) num_unread++;
|
|
|
|
auto p = &unread[head];
|
|
p->timestamp = _rtc->getCurrentTime();
|
|
if (path_len == 0xFF) {
|
|
sprintf(p->origin, "(D) %s:", from_name);
|
|
} else {
|
|
sprintf(p->origin, "(%d) %s:", (uint32_t) path_len, from_name);
|
|
}
|
|
StrHelper::strncpy(p->msg, msg, sizeof(p->msg));
|
|
}
|
|
|
|
int render(DisplayDriver& display) override {
|
|
char tmp[16];
|
|
display.setCursor(0, 0);
|
|
display.setTextSize(1);
|
|
display.setColor(UIColor::corp_blue);
|
|
sprintf(tmp, "Unread: %d", num_unread);
|
|
display.print(tmp);
|
|
|
|
auto p = &unread[head];
|
|
|
|
int secs = _rtc->getCurrentTime() - p->timestamp;
|
|
if (secs < 60) {
|
|
sprintf(tmp, "%ds", secs);
|
|
} else if (secs < 60*60) {
|
|
sprintf(tmp, "%dm", secs / 60);
|
|
} else {
|
|
sprintf(tmp, "%dh", secs / (60*60));
|
|
}
|
|
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
|
|
display.print(tmp);
|
|
|
|
display.drawRect(0, 11, display.width(), 1); // horiz line
|
|
|
|
display.setCursor(0, 14);
|
|
display.setColor(UIColor::secondary_txt);
|
|
char filtered_origin[sizeof(p->origin)];
|
|
display.translateUTF8ToBlocks(filtered_origin, p->origin, sizeof(filtered_origin));
|
|
display.print(filtered_origin);
|
|
|
|
display.setCursor(0, 25);
|
|
display.setColor(UIColor::primary_txt);
|
|
char filtered_msg[sizeof(p->msg)];
|
|
display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg));
|
|
display.printWordWrap(filtered_msg, display.width());
|
|
|
|
#if AUTO_OFF_MILLIS==0 // probably e-ink
|
|
return 10000; // 10 s
|
|
#else
|
|
return 1000; // next render after 1000 ms
|
|
#endif
|
|
}
|
|
|
|
bool handleInput(char c) override {
|
|
if (c == KEY_NEXT || c == KEY_RIGHT) {
|
|
head = (head + MAX_UNREAD_MSGS - 1) % MAX_UNREAD_MSGS;
|
|
num_unread--;
|
|
if (num_unread == 0) {
|
|
_task->gotoHomeScreen();
|
|
}
|
|
return true;
|
|
}
|
|
if (c == KEY_ENTER) {
|
|
num_unread = 0; // clear unread queue
|
|
_task->gotoHomeScreen();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
};
|
|
|
|
void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) {
|
|
_display = display;
|
|
_sensors = sensors;
|
|
_auto_off = millis() + AUTO_OFF_MILLIS;
|
|
|
|
#if defined(PIN_USER_BTN)
|
|
user_btn.begin();
|
|
#endif
|
|
#if defined(PIN_USER_BTN_ANA)
|
|
analog_btn.begin();
|
|
#endif
|
|
|
|
_node_prefs = node_prefs;
|
|
|
|
// применить сохранённую коррекцию АЦП батареи (0 = как считает плата)
|
|
if (_node_prefs->adc_multiplier > 0.0f) {
|
|
_board->setAdcMultiplier(_node_prefs->adc_multiplier);
|
|
}
|
|
|
|
if (_display != NULL) {
|
|
// применить сохранённую тему дисплея (если драйвер их поддерживает)
|
|
if (_display->getThemeCount() > 0) {
|
|
uint8_t idx = _display->findTheme(_node_prefs->ui_theme);
|
|
if (idx != 0xFF) _display->setTheme(idx);
|
|
}
|
|
_display->turnOn();
|
|
}
|
|
|
|
#ifdef PIN_BUZZER
|
|
buzzer.begin();
|
|
buzzer.quiet(_node_prefs->buzzer_quiet);
|
|
buzzer.startup();
|
|
#endif
|
|
|
|
#ifdef PIN_VIBRATION
|
|
vibration.begin();
|
|
#endif
|
|
|
|
ui_started_at = millis();
|
|
_alert_expiry = 0;
|
|
_msg_pending = false;
|
|
|
|
splash = new SplashScreen(this);
|
|
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
|
|
msg_preview = new MsgPreviewScreen(this, &rtc_clock);
|
|
setCurrScreen(splash);
|
|
}
|
|
|
|
void UITask::showAlert(const char* text, int duration_millis) {
|
|
strcpy(_alert, text);
|
|
_alert_expiry = millis() + duration_millis;
|
|
}
|
|
|
|
void UITask::keepDisplayOn() {
|
|
_auto_off = millis() + AUTO_OFF_MILLIS; // экран не гаснет, пока идёт работа с меню
|
|
}
|
|
|
|
void UITask::blankDisplay() {
|
|
if (_display != NULL && _display->isOn()) {
|
|
_display->turnOff();
|
|
}
|
|
}
|
|
|
|
void UITask::clearAlert() {
|
|
_alert_expiry = 0;
|
|
}
|
|
|
|
void UITask::showPendingMessages() {
|
|
if (_msg_pending) {
|
|
_msg_pending = false;
|
|
if (_msgcount > 0) {
|
|
setCurrScreen(msg_preview);
|
|
}
|
|
}
|
|
}
|
|
|
|
void UITask::notify(UIEventType t) {
|
|
#if defined(PIN_BUZZER)
|
|
switch(t){
|
|
case UIEventType::contactMessage:
|
|
// gemini's pick
|
|
buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7");
|
|
break;
|
|
case UIEventType::channelMessage:
|
|
buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#");
|
|
break;
|
|
case UIEventType::ack:
|
|
buzzer.play("ack:d=32,o=8,b=120:c");
|
|
break;
|
|
case UIEventType::roomMessage:
|
|
case UIEventType::newContactMessage:
|
|
case UIEventType::none:
|
|
default:
|
|
break;
|
|
}
|
|
#endif
|
|
|
|
#ifdef PIN_VIBRATION
|
|
// Trigger vibration for all UI events except none
|
|
if (t != UIEventType::none) {
|
|
vibration.trigger();
|
|
}
|
|
#endif
|
|
}
|
|
|
|
|
|
void UITask::onQueueSizeChanged(int msgcount) {
|
|
_msgcount = msgcount;
|
|
if (msgcount == 0) {
|
|
gotoHomeScreen();
|
|
}
|
|
}
|
|
|
|
void UITask::queueMsg(uint8_t path_len, const char* from_name, const char* text) {
|
|
((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text);
|
|
}
|
|
|
|
// Показывать ли входящее сразу: тихие часы и настройка Popups.
|
|
// Если нет — сообщение только попадает в непрочитанные (счётчик на первом экране).
|
|
static bool showIncomingNow(NodePrefs* prefs, uint32_t utc, bool channel_popup) {
|
|
if (!prefs->msg_popup) return false;
|
|
if (prefs->isQuietNow(utc)) return false;
|
|
return channel_popup;
|
|
}
|
|
|
|
void UITask::onMessageRecv(mesh::Packet *pkt, const ContactInfo &from, uint8_t txt_type, uint32_t sender_timestamp, const char* text) {
|
|
// we only want to show text messages on display, not cli data
|
|
if (!(txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN)) return;
|
|
|
|
uint8_t path_len = pkt->isRouteFlood() ? pkt->path_len : 0xFF;
|
|
queueMsg(path_len, from.name, text);
|
|
|
|
if (!showIncomingNow(_node_prefs, rtc_clock.getCurrentTime(), true)) {
|
|
_msg_pending = true;
|
|
_next_refresh = 100; // чтобы обновился счётчик на первом экране
|
|
return;
|
|
}
|
|
if (curr != NULL && curr->suppressAlerts()) {
|
|
// Экран попросил не перекрывать его (меню настройки): сообщение уже
|
|
// в очереди непрочитанных, покажем его после выхода из меню.
|
|
_msg_pending = true;
|
|
_next_refresh = 100;
|
|
return;
|
|
}
|
|
setCurrScreen(msg_preview);
|
|
|
|
if (_display != NULL) {
|
|
// зажигать экран по входящему можно запретить (в меню уведомлений)
|
|
if (!_display->isOn() && !hasConnection() && _node_prefs->msg_wake_display) {
|
|
_display->turnOn();
|
|
}
|
|
if (_display->isOn()) {
|
|
_auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer
|
|
_next_refresh = 100; // trigger refresh
|
|
}
|
|
}
|
|
if (!hasConnection()) {
|
|
notify(UIEventType::contactMessage);
|
|
}
|
|
}
|
|
|
|
void UITask::onChannelMessageRecv(mesh::Packet *pkt, ChannelDetails& channel_details, const char* text) {
|
|
// Режим канала: OFF — на устройстве не показываем вообще, SILENT — только
|
|
// в непрочитанные, POPUP — как обычно.
|
|
uint8_t ch_mode = _node_prefs->getChannelMode(channel_details.channel_idx);
|
|
if (ch_mode == CH_MODE_OFF) return;
|
|
|
|
uint8_t path_len = pkt->isRouteFlood() ? pkt->path_len : 0xFF;
|
|
queueMsg(path_len, channel_details.name, text);
|
|
|
|
if (!showIncomingNow(_node_prefs, rtc_clock.getCurrentTime(), ch_mode == CH_MODE_POPUP)) {
|
|
_msg_pending = true;
|
|
_next_refresh = 100;
|
|
return;
|
|
}
|
|
if (curr != NULL && curr->suppressAlerts()) {
|
|
_msg_pending = true;
|
|
_next_refresh = 100;
|
|
return;
|
|
}
|
|
setCurrScreen(msg_preview);
|
|
|
|
if (_display != NULL) {
|
|
if (!_display->isOn() && !hasConnection() && _node_prefs->msg_wake_display) {
|
|
_display->turnOn();
|
|
}
|
|
if (_display->isOn()) {
|
|
_auto_off = millis() + AUTO_OFF_MILLIS;
|
|
_next_refresh = 100;
|
|
}
|
|
}
|
|
if (!hasConnection()) {
|
|
notify(UIEventType::channelMessage);
|
|
}
|
|
}
|
|
|
|
void UITask::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) {
|
|
// тихие часы глушат и уведомление о новом контакте
|
|
if (!hasConnection() && !_node_prefs->isQuietNow(rtc_clock.getCurrentTime())) {
|
|
notify(UIEventType::newContactMessage);
|
|
}
|
|
}
|
|
|
|
void UITask::onControlDataRecv(const mesh::Packet* packet) {
|
|
#if UI_DISCOVER_SCREEN
|
|
if (packet->payload_len >= 14 && (packet->payload[0] & 0xF0) == CTL_TYPE_NODE_DISCOVER_RESP) {
|
|
((HomeScreen *) home)->handleDiscoverResponse(packet);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void UITask::showMessages() {
|
|
_msg_pending = false;
|
|
setCurrScreen(msg_preview);
|
|
}
|
|
|
|
void UITask::userLedHandler() {
|
|
#ifdef PIN_STATUS_LED
|
|
int cur_time = millis();
|
|
if (cur_time > next_led_change) {
|
|
if (led_state == 0) {
|
|
led_state = 1;
|
|
if (_msgcount > 0) {
|
|
last_led_increment = LED_ON_MSG_MILLIS;
|
|
} else {
|
|
last_led_increment = LED_ON_MILLIS;
|
|
}
|
|
next_led_change = cur_time + last_led_increment;
|
|
} else {
|
|
led_state = 0;
|
|
next_led_change = cur_time + LED_CYCLE_MILLIS - last_led_increment;
|
|
}
|
|
digitalWrite(PIN_STATUS_LED, led_state == LED_STATE_ON);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void UITask::setCurrScreen(UIScreen* c) {
|
|
curr = c;
|
|
_next_refresh = 100;
|
|
}
|
|
|
|
/*
|
|
hardware-agnostic pre-shutdown activity should be done here
|
|
*/
|
|
void UITask::shutdown(bool restart){
|
|
|
|
#ifdef PIN_BUZZER
|
|
/* note: we have a choice here -
|
|
we can do a blocking buzzer.loop() with non-deterministic consequences
|
|
or we can set a flag and delay the shutdown for a couple of seconds
|
|
while a non-blocking buzzer.loop() plays out in UITask::loop()
|
|
*/
|
|
buzzer.shutdown();
|
|
uint32_t buzzer_timer = millis(); // fail-safe shutdown
|
|
while (buzzer.isPlaying() && (millis() - 2500) < buzzer_timer)
|
|
buzzer.loop();
|
|
|
|
#endif // PIN_BUZZER
|
|
|
|
if (restart) {
|
|
_board->reboot();
|
|
} else {
|
|
// перед выключением гасим экран, иначе на некоторых панелях остаётся картинка
|
|
display.forceFullRefresh();
|
|
display.clear();
|
|
display.endFrame();
|
|
// Power off board including radio, display, GPS and components
|
|
_board->powerOff();
|
|
}
|
|
}
|
|
|
|
bool UITask::isButtonPressed() const {
|
|
#ifdef PIN_USER_BTN
|
|
return user_btn.isPressed();
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
void UITask::loop() {
|
|
char c = 0;
|
|
#if UI_HAS_JOYSTICK
|
|
int ev = user_btn.check();
|
|
if (ev == BUTTON_EVENT_CLICK) {
|
|
c = checkDisplayOn(KEY_ENTER);
|
|
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
|
c = handleLongPress(KEY_ENTER); // REVISIT: could be mapped to different key code
|
|
}
|
|
ev = joystick_left.check();
|
|
if (ev == BUTTON_EVENT_CLICK) {
|
|
c = checkDisplayOn(KEY_LEFT);
|
|
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
|
c = handleLongPress(KEY_LEFT);
|
|
}
|
|
ev = joystick_right.check();
|
|
if (ev == BUTTON_EVENT_CLICK) {
|
|
c = checkDisplayOn(KEY_RIGHT);
|
|
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
|
c = handleLongPress(KEY_RIGHT);
|
|
}
|
|
ev = back_btn.check();
|
|
if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
|
c = handleTripleClick(KEY_SELECT);
|
|
}
|
|
#elif defined(PIN_USER_BTN)
|
|
int ev = user_btn.check();
|
|
#ifdef UI_HAS_NAV_INPUT
|
|
if (ev == BUTTON_EVENT_CLICK) {
|
|
c = checkDisplayOn(KEY_ENTER);
|
|
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
|
display.turnOff();
|
|
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
|
|
c = handleDoubleClick(KEY_SELECT);
|
|
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
|
c = handleTripleClick(KEY_SELECT);
|
|
}
|
|
#else
|
|
if (ev == BUTTON_EVENT_CLICK) {
|
|
c = checkDisplayOn(KEY_NEXT);
|
|
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
|
c = handleLongPress(KEY_ENTER);
|
|
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
|
|
c = handleDoubleClick(KEY_PREV);
|
|
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
|
c = handleTripleClick(KEY_SELECT);
|
|
}
|
|
#endif
|
|
#endif
|
|
#if defined(UI_HAS_ROTARY_INPUT)
|
|
RotaryInputEvent rotaryEv = rotary_input.poll();
|
|
if (c == 0 && _display != NULL && _display->isOn()) {
|
|
if (rotaryEv == RotaryInputEvent::Next) {
|
|
c = KEY_NEXT;
|
|
} else if (rotaryEv == RotaryInputEvent::Prev) {
|
|
c = KEY_PREV;
|
|
}
|
|
}
|
|
#endif
|
|
#if defined(PIN_USER_BTN_ANA)
|
|
if (abs(millis() - _analogue_pin_read_millis) > 10) {
|
|
int ev = analog_btn.check();
|
|
if (ev == BUTTON_EVENT_CLICK) {
|
|
c = checkDisplayOn(KEY_NEXT);
|
|
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
|
c = handleLongPress(KEY_ENTER);
|
|
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
|
|
c = handleDoubleClick(KEY_PREV);
|
|
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
|
c = handleTripleClick(KEY_SELECT);
|
|
}
|
|
_analogue_pin_read_millis = millis();
|
|
}
|
|
#endif
|
|
#if defined(BACKLIGHT_BTN)
|
|
if (millis() > next_backlight_btn_check) {
|
|
bool touch_state = digitalRead(PIN_BUTTON2);
|
|
#if defined(DISP_BACKLIGHT)
|
|
digitalWrite(DISP_BACKLIGHT, !touch_state);
|
|
#elif defined(EXP_PIN_BACKLIGHT)
|
|
expander.digitalWrite(EXP_PIN_BACKLIGHT, !touch_state);
|
|
#endif
|
|
next_backlight_btn_check = millis() + 300;
|
|
}
|
|
#endif
|
|
|
|
if (c != 0 && curr) {
|
|
curr->handleInput(c);
|
|
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
|
|
_next_refresh = 100; // trigger refresh
|
|
}
|
|
|
|
userLedHandler();
|
|
|
|
#ifdef PIN_BUZZER
|
|
if (buzzer.isPlaying()) buzzer.loop();
|
|
#endif
|
|
|
|
if (curr) curr->poll();
|
|
|
|
if (_display != NULL && _display->isOn()) {
|
|
if (millis() >= _next_refresh && curr) {
|
|
_display->startFrame();
|
|
int delay_millis = curr->render(*_display);
|
|
if (millis() < _alert_expiry) { // render alert popup
|
|
_display->setTextSize(1);
|
|
int y = _display->height() / 3;
|
|
int p = _display->height() / 32;
|
|
_display->setColor(UIColor::popup_bkg);
|
|
_display->fillRect(p, y, _display->width() - p*2, y);
|
|
_display->setColor(UIColor::popup_txt); // draw box border
|
|
_display->drawRect(p, y, _display->width() - p*2, y);
|
|
_display->drawTextCentered(_display->width() / 2, y + p*3, _alert);
|
|
_next_refresh = _alert_expiry; // will need refresh when alert is dismissed
|
|
} else {
|
|
_next_refresh = millis() + delay_millis;
|
|
}
|
|
_display->endFrame();
|
|
}
|
|
#if AUTO_OFF_MILLIS > 0
|
|
#ifdef KEEP_DISPLAY_ON_USB
|
|
// Opt-in: refresh the auto-off deadline while externally powered, so the
|
|
// timer counts from the moment external power is removed. Off by default
|
|
// because OLED panels burn in quickly; only enable for LCD targets or
|
|
// where the display is replaceable.
|
|
if (board.isExternalPowered()) {
|
|
_auto_off = millis() + AUTO_OFF_MILLIS;
|
|
}
|
|
#endif
|
|
if (millis() > _auto_off) {
|
|
_display->turnOff();
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#ifdef PIN_VIBRATION
|
|
vibration.loop();
|
|
#endif
|
|
|
|
#ifdef AUTO_SHUTDOWN_MILLIVOLTS
|
|
if (millis() > next_batt_chck) {
|
|
uint16_t milliVolts = getBattMilliVolts();
|
|
if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) {
|
|
if(!board.isExternalPowered()) {
|
|
if (_display != NULL) {
|
|
_display->startFrame();
|
|
_display->setTextSize(2);
|
|
_display->setColor(UIColor::warning_txt);
|
|
_display->drawTextCentered(_display->width() / 2, 20, "Low Battery.");
|
|
_display->drawTextCentered(_display->width() / 2, 40, "Shutting Down!");
|
|
_display->endFrame();
|
|
if (_display->isEink() == false) { delay(3000); }
|
|
}
|
|
shutdown();
|
|
}
|
|
}
|
|
next_batt_chck = millis() + 8000;
|
|
}
|
|
#endif
|
|
}
|
|
|
|
char UITask::checkDisplayOn(char c) {
|
|
if (_display != NULL) {
|
|
if (!_display->isOn()) {
|
|
_display->turnOn(); // turn display on and consume event
|
|
c = 0;
|
|
}
|
|
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
|
|
_next_refresh = 0; // trigger refresh
|
|
}
|
|
return c;
|
|
}
|
|
|
|
char UITask::handleLongPress(char c) {
|
|
if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue
|
|
the_mesh.enterCLIRescue();
|
|
c = 0; // consume event
|
|
}
|
|
return c;
|
|
}
|
|
|
|
char UITask::handleDoubleClick(char c) {
|
|
MESH_DEBUG_PRINTLN("UITask: double-click triggered");
|
|
checkDisplayOn(c);
|
|
return c;
|
|
}
|
|
|
|
char UITask::handleTripleClick(char c) {
|
|
MESH_DEBUG_PRINTLN("UITask: triple click triggered");
|
|
checkDisplayOn(c);
|
|
toggleBuzzer();
|
|
c = 0;
|
|
return c;
|
|
}
|
|
|
|
bool UITask::getGPSState() {
|
|
if (_sensors != NULL) {
|
|
int num = _sensors->getNumSettings();
|
|
for (int i = 0; i < num; i++) {
|
|
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
|
|
return !strcmp(_sensors->getSettingValue(i), "1");
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void UITask::toggleGPS() {
|
|
if (_sensors != NULL) {
|
|
// toggle GPS on/off
|
|
int num = _sensors->getNumSettings();
|
|
for (int i = 0; i < num; i++) {
|
|
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
|
|
if (strcmp(_sensors->getSettingValue(i), "1") == 0) {
|
|
_sensors->setSettingValue("gps", "0");
|
|
_node_prefs->gps_enabled = 0;
|
|
notify(UIEventType::ack);
|
|
} else {
|
|
_sensors->setSettingValue("gps", "1");
|
|
_node_prefs->gps_enabled = 1;
|
|
notify(UIEventType::ack);
|
|
}
|
|
the_mesh.savePrefs();
|
|
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
|
|
_next_refresh = 0;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void UITask::toggleBuzzer() {
|
|
// Toggle buzzer quiet mode
|
|
#ifdef PIN_BUZZER
|
|
if (buzzer.isQuiet()) {
|
|
buzzer.quiet(false);
|
|
notify(UIEventType::ack);
|
|
} else {
|
|
buzzer.quiet(true);
|
|
}
|
|
_node_prefs->buzzer_quiet = buzzer.isQuiet();
|
|
the_mesh.savePrefs();
|
|
showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800);
|
|
_next_refresh = 0; // trigger refresh
|
|
#endif
|
|
}
|