2025-03-04 23:09:43 +11:00
|
|
|
|
#include "UITask.h"
|
|
|
|
|
|
#include <helpers/TxtDataHelpers.h>
|
2025-08-16 20:04:54 +10:00
|
|
|
|
#include "../MyMesh.h"
|
2026-05-12 14:16:37 +02:00
|
|
|
|
#include "../MsgExpand.h"
|
2026-05-24 20:41:25 +02:00
|
|
|
|
#include "../Features.h"
|
2026-06-03 08:20:59 +02:00
|
|
|
|
#include "../GeoUtils.h"
|
2025-08-08 20:01:31 +10:00
|
|
|
|
#include "target.h"
|
2025-10-27 17:58:29 +01:00
|
|
|
|
#ifdef WIFI_SSID
|
|
|
|
|
|
#include <WiFi.h>
|
|
|
|
|
|
#endif
|
2025-03-04 23:09:43 +11:00
|
|
|
|
|
2025-09-03 18:17:37 +02:00
|
|
|
|
#ifndef AUTO_OFF_MILLIS
|
|
|
|
|
|
#define AUTO_OFF_MILLIS 15000 // 15 seconds
|
|
|
|
|
|
#endif
|
2025-06-07 15:57:22 -07:00
|
|
|
|
#define BOOT_SCREEN_MILLIS 3000 // 3 seconds
|
2025-03-04 23:09:43 +11:00
|
|
|
|
|
2025-04-24 21:54:37 -04:00
|
|
|
|
#ifdef PIN_STATUS_LED
|
|
|
|
|
|
#define LED_ON_MILLIS 20
|
|
|
|
|
|
#define LED_ON_MSG_MILLIS 200
|
|
|
|
|
|
#define LED_CYCLE_MILLIS 4000
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
2025-08-08 20:01:31 +10:00
|
|
|
|
#define LONG_PRESS_MILLIS 1200
|
2025-03-10 22:42:52 +01:00
|
|
|
|
|
2025-08-16 18:13:50 +02:00
|
|
|
|
#ifndef UI_RECENT_LIST_SIZE
|
|
|
|
|
|
#define UI_RECENT_LIST_SIZE 4
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
2025-10-16 17:33:22 +11:00
|
|
|
|
#if UI_HAS_JOYSTICK
|
|
|
|
|
|
#define PRESS_LABEL "press Enter"
|
|
|
|
|
|
#else
|
|
|
|
|
|
#define PRESS_LABEL "long press"
|
|
|
|
|
|
#endif
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
|
|
|
|
|
#include "icons.h"
|
|
|
|
|
|
|
|
|
|
|
|
class SplashScreen : public UIScreen {
|
|
|
|
|
|
UITask* _task;
|
|
|
|
|
|
unsigned long dismiss_after;
|
|
|
|
|
|
char _version_info[12];
|
2026-05-29 12:46:29 +02:00
|
|
|
|
char _solo_ver[12];
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
|
|
|
|
|
public:
|
|
|
|
|
|
SplashScreen(UITask* task) : _task(task) {
|
2026-05-24 00:35:07 +02:00
|
|
|
|
// MeshCore upstream version shown large (e.g. "1.15")
|
|
|
|
|
|
strncpy(_version_info, MESHCORE_VERSION, sizeof(_version_info) - 1);
|
|
|
|
|
|
_version_info[sizeof(_version_info) - 1] = '\0';
|
|
|
|
|
|
|
2026-05-29 12:46:29 +02:00
|
|
|
|
// Solo firmware version: strip commit hash suffix (v1.15-solo.1-abcdef -> v1.15)
|
2025-08-08 20:01:31 +10:00
|
|
|
|
const char *ver = FIRMWARE_VERSION;
|
|
|
|
|
|
const char *dash = strchr(ver, '-');
|
2026-05-24 00:35:07 +02:00
|
|
|
|
int plen = dash ? (int)(dash - ver) : (int)strlen(ver);
|
2026-05-29 12:46:29 +02:00
|
|
|
|
if (plen >= (int)sizeof(_solo_ver)) plen = sizeof(_solo_ver) - 1;
|
|
|
|
|
|
memcpy(_solo_ver, ver, plen);
|
|
|
|
|
|
_solo_ver[plen] = '\0';
|
2026-05-16 00:33:13 +02:00
|
|
|
|
|
2025-08-08 20:01:31 +10:00
|
|
|
|
dismiss_after = millis() + BOOT_SCREEN_MILLIS;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int render(DisplayDriver& display) override {
|
2026-05-22 18:05:09 +02:00
|
|
|
|
display.setTextSize(1);
|
|
|
|
|
|
const int lh = display.getLineHeight();
|
|
|
|
|
|
const int step = display.lineStep();
|
|
|
|
|
|
|
2025-08-08 20:01:31 +10:00
|
|
|
|
// meshcore logo
|
2026-05-11 15:56:30 +02:00
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-05-22 23:53:16 +02:00
|
|
|
|
int logoWidth = 128;
|
2026-05-22 18:05:09 +02:00
|
|
|
|
int logo_y = 3;
|
|
|
|
|
|
display.drawXbm((display.width() - logoWidth) / 2, logo_y, meshcore_logo, logoWidth, 13);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
2026-05-22 18:05:09 +02:00
|
|
|
|
// version info at sz2
|
|
|
|
|
|
int ver_y = logo_y + 13 + 2;
|
2025-08-08 20:01:31 +10:00
|
|
|
|
display.setTextSize(2);
|
2026-05-22 18:34:20 +02:00
|
|
|
|
int lh2 = display.getLineHeight();
|
2026-05-22 18:05:09 +02:00
|
|
|
|
display.drawTextCentered(display.width()/2, ver_y, _version_info);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
2026-05-22 18:34:20 +02:00
|
|
|
|
// build date at sz1, below sz2 version
|
|
|
|
|
|
int date_y = ver_y + lh2 + 2;
|
2025-08-08 20:01:31 +10:00
|
|
|
|
display.setTextSize(1);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
display.drawTextCentered(display.width()/2, date_y, FIRMWARE_BUILD_DATE);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
2026-05-29 12:25:34 +02:00
|
|
|
|
#ifdef FIRMWARE_SOLO_BUILD
|
2026-05-29 12:46:29 +02:00
|
|
|
|
int solo_y = date_y + step;
|
|
|
|
|
|
display.fillRect(0, solo_y - 1, display.width(), lh + 2);
|
2026-05-11 15:56:30 +02:00
|
|
|
|
display.setColor(DisplayDriver::DARK);
|
2026-05-29 12:46:29 +02:00
|
|
|
|
char solo_label[24];
|
|
|
|
|
|
if (_solo_ver[0])
|
|
|
|
|
|
snprintf(solo_label, sizeof(solo_label), "Solo %s for Wio", _solo_ver);
|
2026-05-16 00:33:13 +02:00
|
|
|
|
else
|
2026-05-29 12:46:29 +02:00
|
|
|
|
snprintf(solo_label, sizeof(solo_label), "Solo for Wio");
|
|
|
|
|
|
display.drawTextCentered(display.width()/2, solo_y, solo_label);
|
2026-05-11 15:56:30 +02:00
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-05-10 22:58:46 +02:00
|
|
|
|
#endif
|
|
|
|
|
|
|
2025-08-08 20:01:31 +10:00
|
|
|
|
return 1000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void poll() override {
|
|
|
|
|
|
if (millis() >= dismiss_after) {
|
|
|
|
|
|
_task->gotoHomeScreen();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-11 16:52:24 +02:00
|
|
|
|
static const int QUICK_MSGS_MAX = 10;
|
|
|
|
|
|
|
2026-05-12 09:51:30 +02:00
|
|
|
|
|
2026-05-13 18:22:41 +02:00
|
|
|
|
#include "KeyboardWidget.h"
|
2026-05-13 23:54:26 +02:00
|
|
|
|
#include "FullscreenMsgView.h"
|
2026-05-14 00:12:07 +02:00
|
|
|
|
#include "SensorPlaceholders.h"
|
2026-05-17 00:12:40 +02:00
|
|
|
|
#include "SettingsScreen.h"
|
|
|
|
|
|
#include "QuickMsgScreen.h"
|
2026-05-10 18:45:35 +02:00
|
|
|
|
|
2026-05-12 14:45:02 +02:00
|
|
|
|
// ── Custom screens (separate files to ease upstream merges) ───────────────────
|
|
|
|
|
|
#include "RingtoneEditorScreen.h"
|
|
|
|
|
|
#include "BotScreen.h"
|
2026-05-14 11:28:36 +02:00
|
|
|
|
#include "NearbyScreen.h"
|
|
|
|
|
|
#include "DashboardConfigScreen.h"
|
2026-05-14 16:08:33 +02:00
|
|
|
|
#include "AutoAdvertScreen.h"
|
2026-05-25 09:10:40 +02:00
|
|
|
|
#include "TrailScreen.h"
|
2026-06-03 14:07:53 +02:00
|
|
|
|
#include "CompassScreen.h"
|
2026-05-12 14:45:02 +02:00
|
|
|
|
#include "ToolsScreen.h"
|
2026-05-12 09:40:37 +02:00
|
|
|
|
|
2026-05-28 22:57:50 +02:00
|
|
|
|
#ifndef BATT_MIN_MILLIVOLTS
|
|
|
|
|
|
#define BATT_MIN_MILLIVOLTS 3200
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
|
|
// LiPo discharge curve: voltage (mV) → raw capacity (%). Shared by the top-bar
|
|
|
|
|
|
// battery indicator and the dashboard Batt% field so both report the same
|
|
|
|
|
|
// number for the same voltage. low_mv (typically NodePrefs.low_batt_mv, the
|
|
|
|
|
|
// user-configurable auto-shutdown threshold in Settings) is rescaled to 0%
|
|
|
|
|
|
// so the bar empties at the cutoff the user actually cares about.
|
|
|
|
|
|
static int battMvToPercent(int mv, int low_mv) {
|
|
|
|
|
|
static const struct { uint16_t mv; uint8_t pct; } CURVE[] = {
|
|
|
|
|
|
{3200, 0}, {3300, 3}, {3400, 8}, {3500, 15},
|
|
|
|
|
|
{3600, 25}, {3650, 33}, {3700, 45}, {3750, 58},
|
|
|
|
|
|
{3800, 68}, {3900, 77}, {4000, 86}, {4100, 93}, {4200, 100}
|
|
|
|
|
|
};
|
|
|
|
|
|
static const int CURVE_LEN = sizeof(CURVE) / sizeof(CURVE[0]);
|
|
|
|
|
|
auto curveAt = [&](int v) -> int {
|
|
|
|
|
|
if (v <= (int)CURVE[0].mv) return CURVE[0].pct;
|
|
|
|
|
|
if (v >= (int)CURVE[CURVE_LEN-1].mv) return CURVE[CURVE_LEN-1].pct;
|
|
|
|
|
|
for (int i = 1; i < CURVE_LEN; i++) {
|
|
|
|
|
|
if (v <= (int)CURVE[i].mv) {
|
|
|
|
|
|
int span_mv = CURVE[i].mv - CURVE[i-1].mv;
|
|
|
|
|
|
int span_pct = CURVE[i].pct - CURVE[i-1].pct;
|
|
|
|
|
|
return CURVE[i-1].pct + (v - (int)CURVE[i-1].mv) * span_pct / span_mv;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return 100;
|
|
|
|
|
|
};
|
|
|
|
|
|
if (low_mv <= 0) low_mv = BATT_MIN_MILLIVOLTS;
|
|
|
|
|
|
int raw_pct = curveAt(mv);
|
|
|
|
|
|
int low_pct = curveAt(low_mv);
|
|
|
|
|
|
int pct = (low_pct >= 100) ? 0 : (raw_pct - low_pct) * 100 / (100 - low_pct);
|
|
|
|
|
|
if (pct < 0) pct = 0;
|
|
|
|
|
|
if (pct > 100) pct = 100;
|
|
|
|
|
|
return pct;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 23:32:27 +02:00
|
|
|
|
// Render the time starting at top_y; returns the y just below the time block
|
|
|
|
|
|
// so the caller can flow the date / dashboard rows beneath it.
|
|
|
|
|
|
//
|
|
|
|
|
|
// On a tall portrait panel (e-ink in portrait — height > width) HH and MM are
|
|
|
|
|
|
// stacked on two lines in the huge built-in font (size 4, ~56 px tall) so the
|
|
|
|
|
|
// digits fill the narrow width. On wide panels (OLED, landscape e-ink) the
|
|
|
|
|
|
// classic single-line "HH:MM" at size 2 is kept.
|
|
|
|
|
|
static int drawClockTime(DisplayDriver& d, int top_y, const struct tm* ti,
|
|
|
|
|
|
bool h12, bool show_sec) {
|
|
|
|
|
|
const bool tall = d.height() > d.width(); // true only on portrait e-ink
|
|
|
|
|
|
|
|
|
|
|
|
if (tall) {
|
|
|
|
|
|
int hh = ti->tm_hour;
|
|
|
|
|
|
const char* ap = nullptr;
|
|
|
|
|
|
if (h12) { ap = (hh < 12) ? "AM" : "PM"; hh %= 12; if (hh == 0) hh = 12; }
|
|
|
|
|
|
const int cx = d.width() / 2;
|
|
|
|
|
|
char hbuf[4], mbuf[4];
|
|
|
|
|
|
snprintf(hbuf, sizeof(hbuf), "%02d", hh);
|
|
|
|
|
|
snprintf(mbuf, sizeof(mbuf), "%02d", ti->tm_min);
|
|
|
|
|
|
|
|
|
|
|
|
int y = top_y;
|
|
|
|
|
|
d.setTextSize(4);
|
|
|
|
|
|
const int lhb = d.getLineHeight();
|
|
|
|
|
|
// The built-in GFX font advances 6 px per char but the glyph is only 5 px
|
|
|
|
|
|
// wide, so getTextWidth() over-reports by one trailing blank column and
|
|
|
|
|
|
// drawTextCentered() would bias the digits ~half a column to the left.
|
|
|
|
|
|
// Centre on the visible width (minus that trailing column) instead.
|
|
|
|
|
|
const int trail = d.getCharWidth() / 6; // one built-in column at this size
|
|
|
|
|
|
auto drawBig = [&](const char* s, int yy) {
|
|
|
|
|
|
int w = (int)d.getTextWidth(s) - trail;
|
|
|
|
|
|
d.setCursor(cx - w / 2, yy);
|
|
|
|
|
|
d.print(s);
|
|
|
|
|
|
};
|
|
|
|
|
|
drawBig(hbuf, y); y += lhb + 2;
|
|
|
|
|
|
drawBig(mbuf, y); y += lhb + 2;
|
|
|
|
|
|
if (ap) { d.setTextSize(2); d.drawTextCentered(cx, y, ap); y += d.getLineHeight() + 1; }
|
|
|
|
|
|
d.setTextSize(1);
|
|
|
|
|
|
return y;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Wide layout: single inline line at size 2.
|
|
|
|
|
|
char buf[16];
|
|
|
|
|
|
d.setTextSize(2);
|
|
|
|
|
|
const int lh2 = d.getLineHeight();
|
|
|
|
|
|
if (h12) {
|
|
|
|
|
|
int hh = ti->tm_hour % 12; if (hh == 0) hh = 12;
|
|
|
|
|
|
const char* ap = (ti->tm_hour < 12) ? "AM" : "PM";
|
|
|
|
|
|
if (show_sec) snprintf(buf, sizeof(buf), "%d:%02d:%02d%s", hh, ti->tm_min, ti->tm_sec, ap);
|
|
|
|
|
|
else snprintf(buf, sizeof(buf), "%d:%02d %s", hh, ti->tm_min, ap);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (show_sec) snprintf(buf, sizeof(buf), "%02d:%02d:%02d", ti->tm_hour, ti->tm_min, ti->tm_sec);
|
|
|
|
|
|
else snprintf(buf, sizeof(buf), "%02d:%02d", ti->tm_hour, ti->tm_min);
|
|
|
|
|
|
}
|
|
|
|
|
|
d.drawTextCentered(d.width() / 2, top_y, buf);
|
|
|
|
|
|
d.setTextSize(1);
|
|
|
|
|
|
return top_y + lh2 + 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 09:40:37 +02:00
|
|
|
|
// ── HomeScreen ────────────────────────────────────────────────────────────────
|
2025-08-08 20:01:31 +10:00
|
|
|
|
class HomeScreen : public UIScreen {
|
|
|
|
|
|
enum HomePage {
|
2026-05-10 15:18:35 +02:00
|
|
|
|
CLOCK,
|
2026-05-24 23:00:27 +02:00
|
|
|
|
FAVOURITES,
|
2025-08-08 20:01:31 +10:00
|
|
|
|
RECENT,
|
|
|
|
|
|
RADIO,
|
|
|
|
|
|
BLUETOOTH,
|
|
|
|
|
|
ADVERT,
|
2025-09-28 09:43:28 +02:00
|
|
|
|
#if ENV_INCLUDE_GPS == 1
|
2025-09-23 10:39:43 +02:00
|
|
|
|
GPS,
|
|
|
|
|
|
#endif
|
2025-09-09 16:32:41 +02:00
|
|
|
|
#if UI_SENSORS_PAGE == 1
|
2025-09-05 15:20:52 +02:00
|
|
|
|
SENSORS,
|
2025-09-09 16:32:41 +02:00
|
|
|
|
#endif
|
2026-05-10 15:18:35 +02:00
|
|
|
|
SETTINGS,
|
2026-05-12 09:40:37 +02:00
|
|
|
|
TOOLS,
|
2026-05-10 18:45:35 +02:00
|
|
|
|
QUICK_MSG,
|
2025-08-08 20:01:31 +10:00
|
|
|
|
SHUTDOWN,
|
|
|
|
|
|
Count // keep as last
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-24 23:00:27 +02:00
|
|
|
|
// Selected slot on the Favourites page (0..FAVOURITES_COUNT - 1).
|
|
|
|
|
|
uint8_t _fav_sel = 0;
|
|
|
|
|
|
|
2026-05-25 08:08:12 +02:00
|
|
|
|
// Build the in-place pin picker list for an empty slot. Favourited chat
|
|
|
|
|
|
// contacts first (`c.flags & 0x01`), then recent DM contacts deduped
|
|
|
|
|
|
// against the favourites list. Up to PIN_PICKER_MAX.
|
|
|
|
|
|
void buildPinPicker(int slot) {
|
|
|
|
|
|
_pin_target_slot = slot;
|
|
|
|
|
|
_pin_count = 0;
|
|
|
|
|
|
// 1) Upstream-favourited chat contacts.
|
|
|
|
|
|
for (int idx = 0; _pin_count < PIN_PICKER_MAX; idx++) {
|
|
|
|
|
|
ContactInfo c;
|
|
|
|
|
|
if (!the_mesh.getContactByIdx(idx, c)) break;
|
|
|
|
|
|
if (c.type != ADV_TYPE_CHAT) continue;
|
|
|
|
|
|
if (!(c.flags & 0x01)) continue;
|
|
|
|
|
|
memcpy(_pin_keys[_pin_count], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
|
|
|
|
|
|
DisplayDriver::translateUTF8Static(_pin_labels[_pin_count], c.name, sizeof(_pin_labels[_pin_count]));
|
|
|
|
|
|
_pin_count++;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 2) Recent DM contacts (deduped).
|
|
|
|
|
|
uint8_t recent[PIN_PICKER_MAX][NodePrefs::FAVOURITE_PREFIX_LEN];
|
|
|
|
|
|
int rn = _task->getRecentDMContacts(recent, PIN_PICKER_MAX);
|
|
|
|
|
|
for (int i = 0; i < rn && _pin_count < PIN_PICKER_MAX; i++) {
|
|
|
|
|
|
bool dup = false;
|
|
|
|
|
|
for (int j = 0; j < _pin_count; j++)
|
|
|
|
|
|
if (memcmp(_pin_keys[j], recent[i], NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { dup = true; break; }
|
|
|
|
|
|
if (dup) continue;
|
|
|
|
|
|
for (int idx = 0; ; idx++) {
|
|
|
|
|
|
ContactInfo c;
|
|
|
|
|
|
if (!the_mesh.getContactByIdx(idx, c)) break;
|
|
|
|
|
|
if (memcmp(c.id.pub_key, recent[i], NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
|
|
|
|
|
|
memcpy(_pin_keys[_pin_count], recent[i], NodePrefs::FAVOURITE_PREFIX_LEN);
|
|
|
|
|
|
DisplayDriver::translateUTF8Static(_pin_labels[_pin_count], c.name, sizeof(_pin_labels[_pin_count]));
|
|
|
|
|
|
_pin_count++;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-29 10:59:34 +02:00
|
|
|
|
// 3) Fallback: all remaining chat contacts not already in the list.
|
2026-05-25 08:08:12 +02:00
|
|
|
|
if (_pin_count == 0) {
|
2026-05-29 10:59:34 +02:00
|
|
|
|
for (int idx = 0; _pin_count < PIN_PICKER_MAX; idx++) {
|
|
|
|
|
|
ContactInfo c;
|
|
|
|
|
|
if (!the_mesh.getContactByIdx(idx, c)) break;
|
|
|
|
|
|
if (c.type != ADV_TYPE_CHAT) continue;
|
|
|
|
|
|
bool dup = false;
|
|
|
|
|
|
for (int j = 0; j < _pin_count; j++)
|
|
|
|
|
|
if (memcmp(_pin_keys[j], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { dup = true; break; }
|
|
|
|
|
|
if (dup) continue;
|
|
|
|
|
|
memcpy(_pin_keys[_pin_count], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
|
|
|
|
|
|
DisplayDriver::translateUTF8Static(_pin_labels[_pin_count], c.name, sizeof(_pin_labels[_pin_count]));
|
|
|
|
|
|
_pin_count++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (_pin_count == 0) {
|
|
|
|
|
|
_task->showAlert("No contacts", 1000);
|
2026-05-25 08:08:12 +02:00
|
|
|
|
_pin_target_slot = -1;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
_pin_menu.begin("Pick contact", 3);
|
|
|
|
|
|
for (int i = 0; i < _pin_count; i++) _pin_menu.addItem(_pin_labels[i]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// In-place pin picker (opens when Enter hits an empty Favourites tile).
|
|
|
|
|
|
static const int PIN_PICKER_MAX = 12;
|
|
|
|
|
|
PopupMenu _pin_menu;
|
|
|
|
|
|
uint8_t _pin_keys[PIN_PICKER_MAX][NodePrefs::FAVOURITE_PREFIX_LEN];
|
|
|
|
|
|
char _pin_labels[PIN_PICKER_MAX][22];
|
|
|
|
|
|
int _pin_count = 0;
|
|
|
|
|
|
int _pin_target_slot = -1;
|
|
|
|
|
|
|
2025-08-08 20:01:31 +10:00
|
|
|
|
UITask* _task;
|
|
|
|
|
|
mesh::RTCClock* _rtc;
|
|
|
|
|
|
SensorManager* _sensors;
|
|
|
|
|
|
NodePrefs* _node_prefs;
|
|
|
|
|
|
uint8_t _page;
|
|
|
|
|
|
bool _shutdown_init;
|
2025-08-16 18:13:50 +02:00
|
|
|
|
AdvertPath recent[UI_RECENT_LIST_SIZE];
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
2026-05-12 09:51:30 +02:00
|
|
|
|
int pageBit(int page) const {
|
2026-05-24 23:00:27 +02:00
|
|
|
|
if (page == CLOCK) return NodePrefs::HPB_CLOCK;
|
|
|
|
|
|
if (page == FAVOURITES) return NodePrefs::HPB_FAVOURITES;
|
2026-05-24 22:26:56 +02:00
|
|
|
|
if (page == RECENT) return NodePrefs::HPB_RECENT;
|
|
|
|
|
|
if (page == RADIO) return NodePrefs::HPB_RADIO;
|
|
|
|
|
|
if (page == BLUETOOTH) return NodePrefs::HPB_BLUETOOTH;
|
|
|
|
|
|
if (page == ADVERT) return NodePrefs::HPB_ADVERT;
|
2026-05-12 09:51:30 +02:00
|
|
|
|
#if ENV_INCLUDE_GPS == 1
|
2026-05-24 22:26:56 +02:00
|
|
|
|
if (page == GPS) return NodePrefs::HPB_GPS;
|
2026-05-12 09:51:30 +02:00
|
|
|
|
#endif
|
|
|
|
|
|
#if UI_SENSORS_PAGE == 1
|
2026-05-24 22:26:56 +02:00
|
|
|
|
if (page == SENSORS) return NodePrefs::HPB_SENSORS;
|
2026-05-12 09:51:30 +02:00
|
|
|
|
#endif
|
2026-05-24 22:26:56 +02:00
|
|
|
|
if (page == TOOLS) return NodePrefs::HPB_TOOLS;
|
|
|
|
|
|
if (page == SHUTDOWN) return NodePrefs::HPB_SHUTDOWN;
|
|
|
|
|
|
return -1; // SETTINGS, QUICK_MSG always visible (no mask bit)
|
2026-05-12 09:51:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 22:26:56 +02:00
|
|
|
|
// Maps page_order bit-index back to the HomePage enum value for this build.
|
2026-05-22 23:33:06 +02:00
|
|
|
|
// Returns -1 if the page is not compiled in.
|
|
|
|
|
|
int bitToPage(int bit) const {
|
|
|
|
|
|
switch (bit) {
|
2026-05-24 23:00:27 +02:00
|
|
|
|
case NodePrefs::HPB_CLOCK: return CLOCK;
|
|
|
|
|
|
case NodePrefs::HPB_FAVOURITES: return FAVOURITES;
|
2026-05-24 22:26:56 +02:00
|
|
|
|
case NodePrefs::HPB_RECENT: return RECENT;
|
|
|
|
|
|
case NodePrefs::HPB_RADIO: return RADIO;
|
|
|
|
|
|
case NodePrefs::HPB_BLUETOOTH: return BLUETOOTH;
|
|
|
|
|
|
case NodePrefs::HPB_ADVERT: return ADVERT;
|
2026-05-22 23:33:06 +02:00
|
|
|
|
#if ENV_INCLUDE_GPS == 1
|
2026-05-24 22:26:56 +02:00
|
|
|
|
case NodePrefs::HPB_GPS: return GPS;
|
2026-05-22 23:33:06 +02:00
|
|
|
|
#endif
|
|
|
|
|
|
#if UI_SENSORS_PAGE == 1
|
2026-05-24 22:26:56 +02:00
|
|
|
|
case NodePrefs::HPB_SENSORS: return SENSORS;
|
2026-05-22 23:33:06 +02:00
|
|
|
|
#endif
|
2026-05-24 22:26:56 +02:00
|
|
|
|
case NodePrefs::HPB_TOOLS: return TOOLS;
|
|
|
|
|
|
case NodePrefs::HPB_SHUTDOWN: return SHUTDOWN;
|
|
|
|
|
|
case NodePrefs::HPB_SETTINGS: return SETTINGS;
|
|
|
|
|
|
case NodePrefs::HPB_QUICK_MSG: return QUICK_MSG;
|
2026-05-22 23:33:06 +02:00
|
|
|
|
default: return -1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 09:51:30 +02:00
|
|
|
|
bool isPageVisible(int page) const {
|
|
|
|
|
|
int bit = pageBit(page);
|
|
|
|
|
|
if (bit < 0) return true;
|
2026-05-23 20:03:45 +02:00
|
|
|
|
uint16_t mask = (_node_prefs && _node_prefs->home_pages_mask) ? _node_prefs->home_pages_mask : NodePrefs::HP_ALL;
|
2026-05-12 09:51:30 +02:00
|
|
|
|
return (mask >> bit) & 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 23:33:06 +02:00
|
|
|
|
// Build ordered list of all visible pages, respecting page_order when set.
|
|
|
|
|
|
// Returns count; out[] receives HomePage enum values.
|
|
|
|
|
|
int buildVisibleOrder(int* out) const {
|
|
|
|
|
|
int n = 0;
|
2026-05-24 19:53:05 +02:00
|
|
|
|
bool custom = _node_prefs && _node_prefs->page_order_set == NodePrefs::PAGE_ORDER_MAGIC;
|
2026-05-22 23:33:06 +02:00
|
|
|
|
if (custom) {
|
2026-05-24 23:00:27 +02:00
|
|
|
|
for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) {
|
2026-05-22 23:33:06 +02:00
|
|
|
|
uint8_t v = _node_prefs->page_order[i];
|
2026-05-24 22:26:56 +02:00
|
|
|
|
if (v < 1 || v > NodePrefs::HPB_COUNT) break;
|
2026-05-22 23:33:06 +02:00
|
|
|
|
int pg = bitToPage(v - 1);
|
|
|
|
|
|
if (pg >= 0 && pg < (int)Count && isPageVisible(pg)) out[n++] = pg;
|
|
|
|
|
|
}
|
2026-05-23 18:11:24 +02:00
|
|
|
|
// Append any visible page missing from page_order (handles corrupted/migrated prefs)
|
|
|
|
|
|
for (int pg = 0; pg < (int)Count; pg++) {
|
|
|
|
|
|
if (!isPageVisible(pg)) continue;
|
|
|
|
|
|
bool found = false;
|
|
|
|
|
|
for (int i = 0; i < n; i++) if (out[i] == pg) { found = true; break; }
|
|
|
|
|
|
if (!found) out[n++] = pg;
|
|
|
|
|
|
}
|
2026-05-22 23:33:06 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
for (int pg = 0; pg < (int)Count; pg++)
|
|
|
|
|
|
if (isPageVisible(pg)) out[n++] = pg;
|
2026-05-12 09:51:30 +02:00
|
|
|
|
}
|
2026-05-22 23:33:06 +02:00
|
|
|
|
return n;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int navPage(int from, int dir) const {
|
2026-05-25 12:59:32 +02:00
|
|
|
|
int order[(int)Count]; int n = buildVisibleOrder(order);
|
2026-05-22 23:33:06 +02:00
|
|
|
|
if (n == 0) return from;
|
|
|
|
|
|
int cur = 0;
|
|
|
|
|
|
for (int i = 0; i < n; i++) if (order[i] == from) { cur = i; break; }
|
|
|
|
|
|
return order[((cur + dir) % n + n) % n];
|
2026-05-12 09:51:30 +02:00
|
|
|
|
}
|
2025-09-16 17:17:15 -07:00
|
|
|
|
|
2026-05-14 18:13:18 +02:00
|
|
|
|
int renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
|
2026-05-28 22:57:50 +02:00
|
|
|
|
int low_mv = _node_prefs ? (int)_node_prefs->low_batt_mv : 0;
|
|
|
|
|
|
int pct = battMvToPercent((int)batteryMilliVolts, low_mv);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
2026-05-10 15:18:35 +02:00
|
|
|
|
uint8_t mode = (_node_prefs && _node_prefs->batt_display_mode < 3)
|
|
|
|
|
|
? _node_prefs->batt_display_mode : 0;
|
|
|
|
|
|
|
|
|
|
|
|
display.setTextSize(1);
|
2026-05-11 15:56:30 +02:00
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
2026-05-22 23:13:27 +02:00
|
|
|
|
const int lh = display.getLineHeight();
|
|
|
|
|
|
const int cw = display.getCharWidth();
|
|
|
|
|
|
const int ind = cw + 2; // single-char indicator width
|
2026-05-23 14:45:24 +02:00
|
|
|
|
const int ind_h = display.isLemonFont() ? lh - 2 : lh;
|
|
|
|
|
|
const int ind_gap = display.isLandscape() ? 3 : 1; // gap between indicator boxes
|
2026-05-22 18:34:20 +02:00
|
|
|
|
|
2026-05-10 15:18:35 +02:00
|
|
|
|
int battLeftX;
|
|
|
|
|
|
if (mode == 1) { // percent
|
|
|
|
|
|
char buf[6];
|
2026-05-24 20:35:49 +02:00
|
|
|
|
snprintf(buf, sizeof(buf),"%d%%", pct);
|
2026-05-10 15:18:35 +02:00
|
|
|
|
battLeftX = display.width() - display.getTextWidth(buf) - 1;
|
|
|
|
|
|
display.setCursor(battLeftX, 0);
|
|
|
|
|
|
display.print(buf);
|
|
|
|
|
|
} else if (mode == 2) { // voltage
|
|
|
|
|
|
char buf[8];
|
2026-05-24 20:35:49 +02:00
|
|
|
|
snprintf(buf, sizeof(buf),"%u.%02uV", batteryMilliVolts / 1000, (batteryMilliVolts % 1000) / 10);
|
2026-05-10 15:18:35 +02:00
|
|
|
|
battLeftX = display.width() - display.getTextWidth(buf) - 1;
|
|
|
|
|
|
display.setCursor(battLeftX, 0);
|
|
|
|
|
|
display.print(buf);
|
2026-05-22 18:34:20 +02:00
|
|
|
|
} else { // icon — scales with lh
|
|
|
|
|
|
const int iconH = lh;
|
2026-05-22 23:13:27 +02:00
|
|
|
|
const int iconW = lh * 2;
|
2026-05-23 14:45:24 +02:00
|
|
|
|
const int bm = display.isLandscape() ? 3 : 2; // inner margin: 3px on landscape e-ink, 2px on OLED/portrait
|
2026-05-22 18:34:20 +02:00
|
|
|
|
battLeftX = display.width() - iconW - 3;
|
|
|
|
|
|
display.drawRect(battLeftX, 0, iconW, iconH);
|
|
|
|
|
|
display.fillRect(battLeftX + iconW, iconH / 4, 2, iconH / 2);
|
2026-05-22 23:13:27 +02:00
|
|
|
|
int fillW = (pct * (iconW - 2 * bm)) / 100;
|
|
|
|
|
|
display.fillRect(battLeftX + bm, bm, fillW, iconH - 2 * bm);
|
2026-05-10 15:18:35 +02:00
|
|
|
|
}
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
2026-02-11 09:51:28 +01:00
|
|
|
|
#ifdef PIN_BUZZER
|
|
|
|
|
|
if (_task->isBuzzerQuiet()) {
|
2026-05-11 15:56:30 +02:00
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-05-22 23:13:27 +02:00
|
|
|
|
int mx = battLeftX - ind - ind_gap;
|
2026-05-23 14:45:24 +02:00
|
|
|
|
display.fillRect(mx, 0, ind, ind_h);
|
2026-05-22 18:34:20 +02:00
|
|
|
|
display.setColor(DisplayDriver::DARK);
|
|
|
|
|
|
display.setCursor(mx + 1, 0);
|
|
|
|
|
|
display.print("M");
|
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
|
battLeftX = mx;
|
2026-02-11 09:51:28 +01:00
|
|
|
|
}
|
|
|
|
|
|
#endif
|
2026-05-11 16:52:24 +02:00
|
|
|
|
|
|
|
|
|
|
// BT connection indicator (left of muted/battery icons)
|
2026-05-14 18:13:18 +02:00
|
|
|
|
int leftmostX = battLeftX;
|
2026-05-11 16:52:24 +02:00
|
|
|
|
if (_task->isSerialEnabled()) {
|
2026-05-22 23:13:27 +02:00
|
|
|
|
int btX = battLeftX - ind - ind_gap;
|
2026-06-05 12:36:49 +02:00
|
|
|
|
if (_task->isBLEConnected()) { // BT icon reflects BLE link, not USB
|
2026-05-11 16:52:24 +02:00
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-05-23 14:45:24 +02:00
|
|
|
|
display.fillRect(btX, 0, ind, ind_h);
|
2026-05-11 16:52:24 +02:00
|
|
|
|
display.setColor(DisplayDriver::DARK);
|
2026-05-22 18:34:20 +02:00
|
|
|
|
display.setCursor(btX + 1, 0);
|
2026-05-11 16:52:24 +02:00
|
|
|
|
display.print("B");
|
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
|
} else {
|
2026-05-22 18:34:20 +02:00
|
|
|
|
display.setCursor(btX + 1, 0);
|
2026-05-11 16:52:24 +02:00
|
|
|
|
display.print("b");
|
|
|
|
|
|
}
|
2026-05-22 23:13:27 +02:00
|
|
|
|
leftmostX = btX - ind_gap;
|
2026-05-14 18:13:18 +02:00
|
|
|
|
|
2026-05-23 10:06:22 +02:00
|
|
|
|
// "A" indicator — left of BT; blinks on OLED, always shown on e-ink
|
2026-05-14 18:13:18 +02:00
|
|
|
|
if (_node_prefs && _node_prefs->advert_auto_interval_sec > 0) {
|
2026-05-22 18:34:20 +02:00
|
|
|
|
int aX = leftmostX - ind;
|
2026-05-24 20:41:25 +02:00
|
|
|
|
bool show_a = Features::BLINK_INDICATORS ? ((millis() % 4000) < 2000) : true;
|
2026-05-23 10:06:22 +02:00
|
|
|
|
if (show_a) {
|
2026-05-14 18:13:18 +02:00
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-05-23 14:45:24 +02:00
|
|
|
|
display.fillRect(aX, 0, ind, ind_h);
|
2026-05-14 18:13:18 +02:00
|
|
|
|
display.setColor(DisplayDriver::DARK);
|
2026-05-22 18:34:20 +02:00
|
|
|
|
display.setCursor(aX + 1, 0);
|
2026-05-14 18:13:18 +02:00
|
|
|
|
display.print("A");
|
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
|
}
|
|
|
|
|
|
leftmostX = aX - 1;
|
|
|
|
|
|
}
|
2026-05-25 08:22:12 +02:00
|
|
|
|
|
2026-05-25 09:10:40 +02:00
|
|
|
|
// "G" indicator — GPS trail logging active. Same blink convention.
|
|
|
|
|
|
if (_task->trail().isActive()) {
|
2026-05-25 08:22:12 +02:00
|
|
|
|
int gX = leftmostX - ind;
|
|
|
|
|
|
bool show_g = Features::BLINK_INDICATORS ? ((millis() % 4000) < 2000) : true;
|
|
|
|
|
|
if (show_g) {
|
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
|
display.fillRect(gX, 0, ind, ind_h);
|
|
|
|
|
|
display.setColor(DisplayDriver::DARK);
|
|
|
|
|
|
display.setCursor(gX + 1, 0);
|
|
|
|
|
|
display.print("G");
|
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
|
}
|
|
|
|
|
|
leftmostX = gX - 1;
|
|
|
|
|
|
}
|
2026-05-11 16:52:24 +02:00
|
|
|
|
}
|
2026-05-14 18:13:18 +02:00
|
|
|
|
return leftmostX;
|
2025-08-08 20:01:31 +10:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-09 16:32:41 +02:00
|
|
|
|
CayenneLPP sensors_lpp;
|
|
|
|
|
|
int sensors_nb = 0;
|
|
|
|
|
|
bool sensors_scroll = false;
|
|
|
|
|
|
int sensors_scroll_offset = 0;
|
2025-09-05 15:20:52 +02:00
|
|
|
|
int next_sensors_refresh = 0;
|
2026-04-28 21:26:56 -07:00
|
|
|
|
|
2025-09-05 15:20:52 +02:00
|
|
|
|
void refresh_sensors() {
|
|
|
|
|
|
if (millis() > next_sensors_refresh) {
|
2025-09-09 16:32:41 +02:00
|
|
|
|
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;
|
2025-09-05 15:20:52 +02:00
|
|
|
|
#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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-08 20:01:31 +10:00
|
|
|
|
public:
|
|
|
|
|
|
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs)
|
2026-04-28 21:26:56 -07:00
|
|
|
|
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
|
2025-09-09 16:32:41 +02:00
|
|
|
|
_shutdown_init(false), sensors_lpp(200) { }
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
|
|
|
|
|
void poll() override {
|
|
|
|
|
|
if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released
|
|
|
|
|
|
_task->shutdown();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int render(DisplayDriver& display) override {
|
|
|
|
|
|
char tmp[80];
|
2026-05-22 18:05:09 +02:00
|
|
|
|
display.setTextSize(1);
|
|
|
|
|
|
const int lh = display.getLineHeight(); // line height at sz1
|
|
|
|
|
|
const int step = display.lineStep(); // lh + 2
|
|
|
|
|
|
const int dots_y = lh + 4; // page-dot row: just below header
|
2026-05-22 23:13:27 +02:00
|
|
|
|
const int content_y = dots_y + 6; // first content row (6px gap keeps dots visible)
|
2026-05-22 18:05:09 +02:00
|
|
|
|
|
2026-05-14 11:28:36 +02:00
|
|
|
|
// node name + battery — hidden on CLOCK page (full screen used for dashboard)
|
|
|
|
|
|
if (_page != CLOCK) {
|
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
|
char filtered_name[sizeof(_node_prefs->node_name)];
|
|
|
|
|
|
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
|
2026-05-14 18:13:18 +02:00
|
|
|
|
int rightEdge = renderBatteryIndicator(display, _task->getBattMilliVolts());
|
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
|
display.drawTextEllipsized(0, 0, rightEdge - 2, filtered_name);
|
2026-05-14 11:28:36 +02:00
|
|
|
|
}
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
2026-05-12 09:51:30 +02:00
|
|
|
|
// ensure current page is visible (e.g. after settings change)
|
2026-05-12 09:57:21 +02:00
|
|
|
|
if (!isPageVisible(_page)) _page = navPage(_page, +1);
|
2026-05-12 09:51:30 +02:00
|
|
|
|
|
2026-05-14 13:28:52 +02:00
|
|
|
|
// curr page indicator — hidden on CLOCK page (full screen used for dashboard)
|
|
|
|
|
|
if (_page != CLOCK) {
|
2026-05-25 12:59:32 +02:00
|
|
|
|
int order[(int)Count]; int n = buildVisibleOrder(order);
|
2026-05-22 23:33:06 +02:00
|
|
|
|
int curr_vis = 0;
|
|
|
|
|
|
for (int i = 0; i < n; i++) if (order[i] == _page) { curr_vis = i; break; }
|
|
|
|
|
|
int x = display.width() / 2 - 5 * (n - 1);
|
|
|
|
|
|
for (int i = 0; i < n; i++) {
|
2026-05-23 14:45:24 +02:00
|
|
|
|
int ds = display.isLandscape() ? 2 : 1;
|
2026-05-22 23:46:35 +02:00
|
|
|
|
if (i == curr_vis) display.fillRect(x-ds, dots_y-ds, 2*ds+1, 2*ds+1);
|
|
|
|
|
|
else display.fillRect(x-ds+1, dots_y-ds+1, 2*ds-1, 2*ds-1);
|
2026-05-22 23:33:06 +02:00
|
|
|
|
x += 10;
|
2025-08-08 20:01:31 +10:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 15:18:35 +02:00
|
|
|
|
if (_page == HomePage::CLOCK) {
|
|
|
|
|
|
uint32_t unix_ts = _rtc->getCurrentTime();
|
|
|
|
|
|
if (unix_ts < 1000000000UL) {
|
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-05-11 15:56:30 +02:00
|
|
|
|
display.setTextSize(1);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
int mid_y = display.height() / 2 - step;
|
|
|
|
|
|
display.drawTextCentered(display.width() / 2, mid_y, "! No time sync");
|
|
|
|
|
|
display.drawTextCentered(display.width() / 2, mid_y + step, "Enable GPS or");
|
|
|
|
|
|
display.drawTextCentered(display.width() / 2, mid_y + step * 2, "connect app");
|
2026-05-10 15:18:35 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
int8_t tz = _node_prefs ? _node_prefs->tz_offset_hours : 0;
|
|
|
|
|
|
unix_ts += (int32_t)tz * 3600;
|
|
|
|
|
|
time_t t = (time_t)unix_ts;
|
|
|
|
|
|
struct tm* ti = gmtime(&t);
|
|
|
|
|
|
|
|
|
|
|
|
char buf[24];
|
2026-05-11 15:56:30 +02:00
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-05-24 20:41:25 +02:00
|
|
|
|
bool show_sec = !Features::IS_EINK && (!_node_prefs || !_node_prefs->clock_hide_seconds);
|
2026-05-21 21:04:43 +02:00
|
|
|
|
bool h12 = _node_prefs && _node_prefs->clock_12h;
|
2026-06-02 23:32:27 +02:00
|
|
|
|
int date_y = drawClockTime(display, 0, ti, h12, show_sec);
|
2026-05-10 15:18:35 +02:00
|
|
|
|
|
|
|
|
|
|
display.setTextSize(1);
|
2026-05-14 11:28:36 +02:00
|
|
|
|
static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"};
|
|
|
|
|
|
static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"};
|
2026-05-24 20:35:49 +02:00
|
|
|
|
snprintf(buf, sizeof(buf),"%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year);
|
2026-05-22 18:34:20 +02:00
|
|
|
|
display.drawTextCentered(display.width() / 2, date_y, buf);
|
2026-05-10 15:18:35 +02:00
|
|
|
|
|
2026-05-22 18:34:20 +02:00
|
|
|
|
int sep_y = date_y + lh + 1;
|
2026-05-22 23:13:27 +02:00
|
|
|
|
int dash0 = sep_y + display.sepH() + 2;
|
|
|
|
|
|
display.fillRect(0, sep_y, display.width(), display.sepH());
|
2026-05-14 11:28:36 +02:00
|
|
|
|
|
|
|
|
|
|
// dashboard data fields
|
|
|
|
|
|
if (_node_prefs) {
|
|
|
|
|
|
refresh_sensors();
|
2026-05-22 18:05:09 +02:00
|
|
|
|
const int FIELD_Y[3] = { dash0, dash0 + step, dash0 + step * 2 };
|
2026-05-14 11:28:36 +02:00
|
|
|
|
for (int fi = 0; fi < 3; fi++) {
|
|
|
|
|
|
uint8_t field = _node_prefs->dashboard_fields[fi];
|
|
|
|
|
|
if (field == DASH_NONE) continue;
|
|
|
|
|
|
|
|
|
|
|
|
char label[10], val[20];
|
|
|
|
|
|
label[0] = '\0';
|
|
|
|
|
|
val[0] = '\0';
|
|
|
|
|
|
|
2026-05-27 22:55:50 +02:00
|
|
|
|
if (field == DASH_BATT_V) {
|
2026-05-14 11:28:36 +02:00
|
|
|
|
strcpy(label, "Batt");
|
|
|
|
|
|
uint16_t mv = _task->getBattMilliVolts();
|
|
|
|
|
|
if (mv > 0) snprintf(val, sizeof(val), "%u.%02uV", mv/1000, (mv%1000)/10);
|
|
|
|
|
|
else strcpy(val, "--");
|
2026-05-27 22:55:50 +02:00
|
|
|
|
} else if (field == DASH_BATT_PCT) {
|
|
|
|
|
|
strcpy(label, "Batt");
|
|
|
|
|
|
uint16_t mv = _task->getBattMilliVolts();
|
2026-05-28 22:57:50 +02:00
|
|
|
|
if (mv > 0) snprintf(val, sizeof(val), "%d%%",
|
|
|
|
|
|
battMvToPercent(mv, _node_prefs->low_batt_mv));
|
|
|
|
|
|
else strcpy(val, "--");
|
2026-05-14 11:28:36 +02:00
|
|
|
|
} else if (field == DASH_GPS) {
|
|
|
|
|
|
strcpy(label, "GPS");
|
|
|
|
|
|
#if ENV_INCLUDE_GPS == 1
|
|
|
|
|
|
LocationProvider* loc = sensors.getLocationProvider();
|
|
|
|
|
|
if (loc && loc->isValid())
|
|
|
|
|
|
snprintf(val, sizeof(val), "%.3f %.3f",
|
|
|
|
|
|
loc->getLatitude()/1000000.0f, loc->getLongitude()/1000000.0f);
|
|
|
|
|
|
else
|
|
|
|
|
|
strcpy(val, "no fix");
|
|
|
|
|
|
#else
|
|
|
|
|
|
strcpy(val, "--");
|
|
|
|
|
|
#endif
|
|
|
|
|
|
} else if (field == DASH_NODES) {
|
|
|
|
|
|
strcpy(label, "Nodes");
|
|
|
|
|
|
snprintf(val, sizeof(val), "%d", the_mesh.getNumContacts());
|
2026-05-14 18:20:49 +02:00
|
|
|
|
} else if (field == DASH_MSGS) {
|
|
|
|
|
|
strcpy(label, "Msgs");
|
|
|
|
|
|
int unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount();
|
|
|
|
|
|
snprintf(val, sizeof(val), "%d", unread);
|
2026-05-14 11:28:36 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
uint8_t lpp_type = 0;
|
|
|
|
|
|
switch (field) {
|
|
|
|
|
|
case DASH_TEMP: strcpy(label, "Temp"); lpp_type = LPP_TEMPERATURE; break;
|
|
|
|
|
|
case DASH_HUM: strcpy(label, "Hum"); lpp_type = LPP_RELATIVE_HUMIDITY; break;
|
|
|
|
|
|
case DASH_PRES: strcpy(label, "Pres"); lpp_type = LPP_BAROMETRIC_PRESSURE; break;
|
|
|
|
|
|
case DASH_ALT: strcpy(label, "Alt"); lpp_type = LPP_ALTITUDE; break;
|
|
|
|
|
|
case DASH_LUX: strcpy(label, "Lux"); lpp_type = LPP_LUMINOSITY; break;
|
|
|
|
|
|
case DASH_CO2: strcpy(label, "CO2"); lpp_type = LPP_CONCENTRATION; break;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (lpp_type) {
|
|
|
|
|
|
LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize());
|
|
|
|
|
|
uint8_t ch, type;
|
|
|
|
|
|
while (r.readHeader(ch, type)) {
|
|
|
|
|
|
if (type == lpp_type) {
|
|
|
|
|
|
float v;
|
|
|
|
|
|
switch (lpp_type) {
|
|
|
|
|
|
case LPP_TEMPERATURE: r.readTemperature(v); snprintf(val, sizeof(val), "%.1f\xf8""C", v); break;
|
|
|
|
|
|
case LPP_RELATIVE_HUMIDITY: r.readRelativeHumidity(v); snprintf(val, sizeof(val), "%.0f%%", v); break;
|
|
|
|
|
|
case LPP_BAROMETRIC_PRESSURE: r.readPressure(v); snprintf(val, sizeof(val), "%.0fhPa", v); break;
|
|
|
|
|
|
case LPP_ALTITUDE: r.readAltitude(v); snprintf(val, sizeof(val), "%.0fm", v); break;
|
|
|
|
|
|
case LPP_LUMINOSITY: r.readLuminosity(v); snprintf(val, sizeof(val), "%.0flux", v); break;
|
|
|
|
|
|
case LPP_CONCENTRATION: r.readConcentration(v); snprintf(val, sizeof(val), "%.0fppm", v); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
r.skipData(type);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!val[0]) strcpy(val, "--");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (val[0] && label[0]) {
|
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
|
display.setCursor(0, FIELD_Y[fi]);
|
|
|
|
|
|
display.print(label);
|
|
|
|
|
|
int vw = display.getTextWidth(val);
|
|
|
|
|
|
display.setCursor(display.width() - vw - 1, FIELD_Y[fi]);
|
|
|
|
|
|
display.print(val);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-10 15:18:35 +02:00
|
|
|
|
}
|
2025-08-08 20:01:31 +10:00
|
|
|
|
} else if (_page == HomePage::RECENT) {
|
2025-08-16 18:13:50 +02:00
|
|
|
|
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
|
2026-05-11 15:56:30 +02:00
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
int y = content_y;
|
|
|
|
|
|
for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += step) {
|
2025-08-08 20:01:31 +10:00
|
|
|
|
auto a = &recent[i];
|
|
|
|
|
|
if (a->name[0] == 0) continue; // empty slot
|
|
|
|
|
|
int secs = _rtc->getCurrentTime() - a->recv_timestamp;
|
|
|
|
|
|
if (secs < 60) {
|
2026-05-24 20:35:49 +02:00
|
|
|
|
snprintf(tmp, sizeof(tmp),"%ds", secs);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
} else if (secs < 60*60) {
|
2026-05-24 20:35:49 +02:00
|
|
|
|
snprintf(tmp, sizeof(tmp),"%dm", secs / 60);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
} else {
|
2026-05-24 20:35:49 +02:00
|
|
|
|
snprintf(tmp, sizeof(tmp),"%dh", secs / (60*60));
|
2025-08-08 20:01:31 +10:00
|
|
|
|
}
|
2026-04-28 21:26:56 -07:00
|
|
|
|
|
2025-09-16 17:17:15 -07:00
|
|
|
|
int timestamp_width = display.getTextWidth(tmp);
|
|
|
|
|
|
int max_name_width = display.width() - timestamp_width - 1;
|
2026-04-28 21:26:56 -07:00
|
|
|
|
|
2025-09-16 17:17:15 -07:00
|
|
|
|
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);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
display.print(tmp);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (_page == HomePage::RADIO) {
|
2026-05-11 15:56:30 +02:00
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
// freq / sf
|
2026-05-22 18:05:09 +02:00
|
|
|
|
display.setCursor(0, content_y);
|
2026-05-24 20:35:49 +02:00
|
|
|
|
snprintf(tmp, sizeof(tmp),"FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
display.print(tmp);
|
|
|
|
|
|
|
2026-05-22 18:05:09 +02:00
|
|
|
|
display.setCursor(0, content_y + step);
|
2026-05-24 20:35:49 +02:00
|
|
|
|
snprintf(tmp, sizeof(tmp),"BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
display.print(tmp);
|
|
|
|
|
|
|
2026-05-22 18:05:09 +02:00
|
|
|
|
// tx power, noise floor
|
|
|
|
|
|
display.setCursor(0, content_y + step * 2);
|
2026-05-24 20:35:49 +02:00
|
|
|
|
snprintf(tmp, sizeof(tmp),"TX: %ddBm", _node_prefs->tx_power_dbm);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
display.print(tmp);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
display.setCursor(0, content_y + step * 3);
|
2026-05-24 20:35:49 +02:00
|
|
|
|
snprintf(tmp, sizeof(tmp),"Noise floor: %d", radio_driver.getNoiseFloor());
|
2025-08-08 20:01:31 +10:00
|
|
|
|
display.print(tmp);
|
|
|
|
|
|
} else if (_page == HomePage::BLUETOOTH) {
|
2026-05-11 15:56:30 +02:00
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
display.setTextSize(1);
|
2026-05-23 14:45:24 +02:00
|
|
|
|
display.drawXbm((display.width() - 32) / 2, content_y,
|
|
|
|
|
|
_task->isSerialEnabled() ? bluetooth_on : bluetooth_off, 32, 32);
|
|
|
|
|
|
const int text_y = content_y + 32 + 3;
|
2026-06-05 12:42:14 +02:00
|
|
|
|
// The pairing PIN is BLE-specific: show it while BLE is on but not yet
|
|
|
|
|
|
// bonded. (Gating on a plain isConnected() broke this on dual builds,
|
|
|
|
|
|
// where it's hardcoded true.)
|
2026-06-05 12:03:38 +02:00
|
|
|
|
const bool waiting_for_pair = _task->isSerialEnabled() && !_task->isBLEConnected() && the_mesh.getBLEPin() != 0;
|
2026-05-23 14:45:24 +02:00
|
|
|
|
if (waiting_for_pair && !display.isLandscape()) {
|
2026-05-11 16:52:24 +02:00
|
|
|
|
char pin_buf[16];
|
|
|
|
|
|
snprintf(pin_buf, sizeof(pin_buf), "PIN: %d", the_mesh.getBLEPin());
|
2026-05-23 14:45:24 +02:00
|
|
|
|
display.drawTextCentered(display.width() / 2, text_y, pin_buf);
|
|
|
|
|
|
} else if (waiting_for_pair) {
|
|
|
|
|
|
char pin_buf[16];
|
|
|
|
|
|
snprintf(pin_buf, sizeof(pin_buf), "PIN: %d", the_mesh.getBLEPin());
|
|
|
|
|
|
display.drawTextCentered(display.width() / 2, text_y, pin_buf);
|
|
|
|
|
|
display.drawTextCentered(display.width() / 2, text_y + step, "toggle: " PRESS_LABEL);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
display.drawTextCentered(display.width() / 2, text_y, "toggle: " PRESS_LABEL);
|
2026-05-11 16:52:24 +02:00
|
|
|
|
}
|
2025-08-08 20:01:31 +10:00
|
|
|
|
} else if (_page == HomePage::ADVERT) {
|
2026-05-11 15:56:30 +02:00
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
display.drawXbm((display.width() - 32) / 2, content_y, advert_icon, 32, 32);
|
|
|
|
|
|
display.drawTextCentered(display.width() / 2, content_y + 32 + 3, "advert: " PRESS_LABEL);
|
2025-09-28 09:43:28 +02:00
|
|
|
|
#if ENV_INCLUDE_GPS == 1
|
2025-09-23 10:39:43 +02:00
|
|
|
|
} else if (_page == HomePage::GPS) {
|
|
|
|
|
|
LocationProvider* nmea = sensors.getLocationProvider();
|
2025-11-28 11:11:13 +01:00
|
|
|
|
char buf[50];
|
2026-05-22 18:05:09 +02:00
|
|
|
|
int y = content_y;
|
2025-11-28 11:11:13 +01:00
|
|
|
|
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.drawTextLeftAlign(0, y, buf);
|
2025-09-23 10:39:43 +02:00
|
|
|
|
if (nmea == NULL) {
|
2026-05-22 18:05:09 +02:00
|
|
|
|
y += step;
|
2025-09-30 09:21:12 +02:00
|
|
|
|
display.drawTextLeftAlign(0, y, "Can't access GPS");
|
2025-09-23 10:39:43 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
strcpy(buf, nmea->isValid()?"fix":"no fix");
|
2025-09-30 09:21:12 +02:00
|
|
|
|
display.drawTextRightAlign(display.width()-1, y, buf);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
y += step;
|
2025-09-30 09:21:12 +02:00
|
|
|
|
display.drawTextLeftAlign(0, y, "sat");
|
2026-05-24 20:35:49 +02:00
|
|
|
|
snprintf(buf, sizeof(buf),"%d", nmea->satellitesCount());
|
2025-09-30 09:21:12 +02:00
|
|
|
|
display.drawTextRightAlign(display.width()-1, y, buf);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
y += step;
|
2025-09-30 09:21:12 +02:00
|
|
|
|
display.drawTextLeftAlign(0, y, "pos");
|
2026-05-24 20:35:49 +02:00
|
|
|
|
snprintf(buf, sizeof(buf),"%.4f %.4f",
|
2025-09-23 10:39:43 +02:00
|
|
|
|
nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.);
|
2025-09-30 09:21:12 +02:00
|
|
|
|
display.drawTextRightAlign(display.width()-1, y, buf);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
y += step;
|
2025-09-30 09:21:12 +02:00
|
|
|
|
display.drawTextLeftAlign(0, y, "alt");
|
2026-05-24 20:35:49 +02:00
|
|
|
|
snprintf(buf, sizeof(buf),"%.2f", nmea->getAltitude()/1000.);
|
2025-09-30 09:21:12 +02:00
|
|
|
|
display.drawTextRightAlign(display.width()-1, y, buf);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
y += step;
|
2025-09-23 10:39:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
#endif
|
2025-09-09 16:32:41 +02:00
|
|
|
|
#if UI_SENSORS_PAGE == 1
|
2025-09-05 15:20:52 +02:00
|
|
|
|
} else if (_page == HomePage::SENSORS) {
|
2026-05-22 18:05:09 +02:00
|
|
|
|
int y = content_y;
|
2025-09-05 15:20:52 +02:00
|
|
|
|
refresh_sensors();
|
2025-09-09 16:32:41 +02:00
|
|
|
|
|
2026-06-06 17:21:41 +02:00
|
|
|
|
// Enumerate the distinct telemetry types directly from the freshly
|
|
|
|
|
|
// populated buffer. (Upstream replaced the per-sensor *_initialized flags
|
|
|
|
|
|
// with a generic registration model, so we derive availability from what
|
|
|
|
|
|
// querySensors() actually produced instead of asking the manager.)
|
2026-05-11 21:33:49 +02:00
|
|
|
|
uint8_t avail_types[16];
|
2026-06-06 17:21:41 +02:00
|
|
|
|
int avail_count = 0;
|
|
|
|
|
|
{
|
|
|
|
|
|
LPPReader er(sensors_lpp.getBuffer(), sensors_lpp.getSize());
|
|
|
|
|
|
uint8_t ech, etype;
|
|
|
|
|
|
while (er.readHeader(ech, etype) && avail_count < 16) {
|
|
|
|
|
|
er.skipData(etype);
|
|
|
|
|
|
bool dup = false;
|
|
|
|
|
|
for (int k = 0; k < avail_count; k++) if (avail_types[k] == etype) { dup = true; break; }
|
|
|
|
|
|
if (!dup) avail_types[avail_count++] = etype;
|
|
|
|
|
|
}
|
2025-09-09 16:32:41 +02:00
|
|
|
|
}
|
2026-05-11 21:33:49 +02:00
|
|
|
|
bool need_scroll = avail_count > UI_RECENT_LIST_SIZE;
|
|
|
|
|
|
int offset = need_scroll ? (sensors_scroll_offset % avail_count) : 0;
|
|
|
|
|
|
int show_n = need_scroll ? UI_RECENT_LIST_SIZE : avail_count;
|
2025-09-09 16:32:41 +02:00
|
|
|
|
|
2026-05-11 21:33:49 +02:00
|
|
|
|
for (int i = 0; i < show_n; i++) {
|
|
|
|
|
|
uint8_t target = avail_types[(offset + i) % avail_count];
|
|
|
|
|
|
|
|
|
|
|
|
// scan LPP buffer for this type
|
|
|
|
|
|
LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize());
|
|
|
|
|
|
uint8_t ch, type;
|
|
|
|
|
|
char buf[22] = "--";
|
|
|
|
|
|
while (r.readHeader(ch, type)) {
|
|
|
|
|
|
if (type == target) {
|
|
|
|
|
|
float v, v2, v3;
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
|
case LPP_GPS:
|
|
|
|
|
|
r.readGPS(v, v2, v3);
|
|
|
|
|
|
if (v != 0 || v2 != 0) snprintf(buf, sizeof(buf), "%.4f %.4f", v, v2);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case LPP_VOLTAGE: r.readVoltage(v); snprintf(buf, sizeof(buf), "%.2fV", v); break;
|
|
|
|
|
|
case LPP_CURRENT: r.readCurrent(v); snprintf(buf, sizeof(buf), "%.3fA", v); break;
|
|
|
|
|
|
case LPP_POWER: r.readPower(v); snprintf(buf, sizeof(buf), "%.1fW", v); break;
|
|
|
|
|
|
case LPP_TEMPERATURE:r.readTemperature(v); snprintf(buf, sizeof(buf), "%.1f\xf8""C", v); break;
|
|
|
|
|
|
case LPP_RELATIVE_HUMIDITY: r.readRelativeHumidity(v); snprintf(buf, sizeof(buf), "%.0f%%", v); break;
|
|
|
|
|
|
case LPP_BAROMETRIC_PRESSURE: r.readPressure(v); snprintf(buf, sizeof(buf), "%.1fhPa", v); break;
|
|
|
|
|
|
case LPP_ALTITUDE: r.readAltitude(v); snprintf(buf, sizeof(buf), "%.0fm", v); break;
|
|
|
|
|
|
case LPP_LUMINOSITY: r.readLuminosity(v); snprintf(buf, sizeof(buf), "%.0flux", v); break;
|
|
|
|
|
|
case LPP_PERCENTAGE: r.readPercentage(v); snprintf(buf, sizeof(buf), "%.0f%%", v); break;
|
|
|
|
|
|
case LPP_DISTANCE: r.readDistance(v); snprintf(buf, sizeof(buf), "%.2fm", v); break;
|
|
|
|
|
|
case LPP_CONCENTRATION: r.readConcentration(v); snprintf(buf, sizeof(buf), "%.0fppm", v); break;
|
|
|
|
|
|
default: r.skipData(type); continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
r.skipData(type);
|
2025-09-09 16:32:41 +02:00
|
|
|
|
}
|
2026-05-11 21:33:49 +02:00
|
|
|
|
|
|
|
|
|
|
static const struct { uint8_t type; const char* name; } TYPE_NAMES[] = {
|
|
|
|
|
|
{ LPP_VOLTAGE, "voltage" },
|
|
|
|
|
|
{ LPP_GPS, "gps" },
|
|
|
|
|
|
{ LPP_TEMPERATURE, "temp" },
|
|
|
|
|
|
{ LPP_RELATIVE_HUMIDITY, "humidity" },
|
|
|
|
|
|
{ LPP_BAROMETRIC_PRESSURE,"pressure" },
|
|
|
|
|
|
{ LPP_ALTITUDE, "altitude" },
|
|
|
|
|
|
{ LPP_CURRENT, "current" },
|
|
|
|
|
|
{ LPP_POWER, "power" },
|
|
|
|
|
|
{ LPP_LUMINOSITY, "light" },
|
|
|
|
|
|
{ LPP_PERCENTAGE, "moisture" },
|
|
|
|
|
|
{ LPP_DISTANCE, "distance" },
|
|
|
|
|
|
{ LPP_CONCENTRATION, "CO2" },
|
|
|
|
|
|
};
|
|
|
|
|
|
const char* name = "sensor";
|
|
|
|
|
|
for (auto& tn : TYPE_NAMES) { if (tn.type == target) { name = tn.name; break; } }
|
2025-09-09 16:32:41 +02:00
|
|
|
|
|
2025-09-05 15:20:52 +02:00
|
|
|
|
display.setCursor(0, y);
|
2025-09-09 16:32:41 +02:00
|
|
|
|
display.print(name);
|
2026-05-11 21:33:49 +02:00
|
|
|
|
display.setCursor(display.width() - display.getTextWidth(buf) - 1, y);
|
2025-09-05 15:20:52 +02:00
|
|
|
|
display.print(buf);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
y += step;
|
2025-09-05 15:20:52 +02:00
|
|
|
|
}
|
2026-05-11 21:33:49 +02:00
|
|
|
|
if (need_scroll) sensors_scroll_offset = (sensors_scroll_offset + 1) % avail_count;
|
2025-09-09 16:32:41 +02:00
|
|
|
|
else sensors_scroll_offset = 0;
|
|
|
|
|
|
#endif
|
2026-05-10 15:18:35 +02:00
|
|
|
|
} else if (_page == HomePage::SETTINGS) {
|
2026-05-11 15:56:30 +02:00
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-05-10 15:18:35 +02:00
|
|
|
|
display.setTextSize(1);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
display.drawTextCentered(display.width() / 2, content_y, "Settings");
|
|
|
|
|
|
display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open");
|
2026-05-12 09:40:37 +02:00
|
|
|
|
} else if (_page == HomePage::TOOLS) {
|
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
|
display.setTextSize(1);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
display.drawTextCentered(display.width() / 2, content_y, "Tools");
|
|
|
|
|
|
display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open");
|
2026-05-10 18:45:35 +02:00
|
|
|
|
} else if (_page == HomePage::QUICK_MSG) {
|
2026-05-11 15:56:30 +02:00
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-05-10 18:45:35 +02:00
|
|
|
|
display.setTextSize(1);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
display.drawTextCentered(display.width() / 2, content_y, "Messages");
|
2026-05-13 10:25:55 +02:00
|
|
|
|
int total_unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount();
|
2026-05-11 19:11:19 +02:00
|
|
|
|
if (total_unread > 0) {
|
|
|
|
|
|
char badge[20];
|
|
|
|
|
|
snprintf(badge, sizeof(badge), "%d unread", total_unread);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
display.drawTextCentered(display.width() / 2, content_y + step, badge);
|
2026-05-11 17:19:03 +02:00
|
|
|
|
}
|
2026-05-22 18:05:09 +02:00
|
|
|
|
display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open");
|
2026-05-24 23:00:27 +02:00
|
|
|
|
} else if (_page == HomePage::FAVOURITES) {
|
|
|
|
|
|
// Grid of pinned contacts. Layout transposes to current orientation:
|
|
|
|
|
|
// landscape → 3×2, portrait → 2×3. Selected tile inverts via drawSelectionRow.
|
2026-05-24 23:17:48 +02:00
|
|
|
|
// No title — node name + battery (top bar) and the page-dots indicator above
|
|
|
|
|
|
// serve as the page identity.
|
2026-05-24 23:00:27 +02:00
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
|
display.setTextSize(1);
|
|
|
|
|
|
|
|
|
|
|
|
const int cols = display.isLandscape() ? 3 : 2;
|
|
|
|
|
|
const int rows = NodePrefs::FAVOURITES_COUNT / cols;
|
2026-05-24 23:17:48 +02:00
|
|
|
|
const int margin = 2;
|
|
|
|
|
|
const int grid_y = content_y + margin;
|
|
|
|
|
|
const int grid_h = display.height() - grid_y - margin;
|
2026-05-24 23:00:27 +02:00
|
|
|
|
const int cell_w = display.width() / cols;
|
|
|
|
|
|
const int cell_h = grid_h / rows;
|
2026-05-24 23:17:48 +02:00
|
|
|
|
const int line_h = display.getLineHeight();
|
2026-05-24 23:00:27 +02:00
|
|
|
|
|
|
|
|
|
|
if (_fav_sel >= NodePrefs::FAVOURITES_COUNT) _fav_sel = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (uint8_t i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) {
|
|
|
|
|
|
int row = i / cols;
|
|
|
|
|
|
int col = i % cols;
|
|
|
|
|
|
int cx = col * cell_w;
|
|
|
|
|
|
int cy = grid_y + row * cell_h;
|
|
|
|
|
|
bool sel = (i == _fav_sel);
|
|
|
|
|
|
display.drawSelectionRow(cx, cy, cell_w - 1, cell_h - 1, sel);
|
|
|
|
|
|
|
|
|
|
|
|
// Empty slot → all-zero prefix. Real keys collide with this with probability 2^-48.
|
|
|
|
|
|
const uint8_t* prefix = _node_prefs ? _node_prefs->favourite_contacts[i] : nullptr;
|
|
|
|
|
|
bool filled = false;
|
|
|
|
|
|
if (prefix) {
|
|
|
|
|
|
for (uint8_t b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++)
|
|
|
|
|
|
if (prefix[b] != 0) { filled = true; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (filled) {
|
|
|
|
|
|
ContactInfo ci;
|
|
|
|
|
|
bool found = false;
|
|
|
|
|
|
for (int idx = 0; ; idx++) {
|
|
|
|
|
|
ContactInfo c;
|
|
|
|
|
|
if (!the_mesh.getContactByIdx(idx, c)) break;
|
|
|
|
|
|
if (memcmp(c.id.pub_key, prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
|
|
|
|
|
|
ci = c; found = true; break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
char name[24];
|
|
|
|
|
|
if (found) display.translateUTF8ToBlocks(name, ci.name, sizeof(name));
|
|
|
|
|
|
else strncpy(name, "(gone)", sizeof(name) - 1), name[sizeof(name) - 1] = '\0';
|
|
|
|
|
|
|
2026-05-25 13:30:12 +02:00
|
|
|
|
// Reserve space for the unread badge so the name's ellipsis lands
|
|
|
|
|
|
// before it instead of underneath. Badge and name share one baseline.
|
|
|
|
|
|
char badge[5] = "";
|
|
|
|
|
|
int bw = 0;
|
2026-05-24 23:00:27 +02:00
|
|
|
|
if (found) {
|
|
|
|
|
|
uint8_t unread = _task->getDMUnread(ci.id.pub_key);
|
|
|
|
|
|
if (unread > 0) {
|
|
|
|
|
|
snprintf(badge, sizeof(badge), "%d", unread);
|
2026-05-25 13:30:12 +02:00
|
|
|
|
bw = display.getTextWidth(badge) + 3; // badge + 3 px gap
|
2026-05-24 23:00:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-25 13:30:12 +02:00
|
|
|
|
int name_y = cy + (cell_h - line_h) / 2;
|
|
|
|
|
|
int name_max_w = cell_w - 4 - bw;
|
|
|
|
|
|
if (name_max_w < 6) name_max_w = 6;
|
|
|
|
|
|
display.drawTextEllipsized(cx + 2, name_y, name_max_w, name);
|
|
|
|
|
|
if (badge[0]) {
|
|
|
|
|
|
display.setCursor(cx + cell_w - (bw - 3) - 2, name_y);
|
|
|
|
|
|
display.print(badge);
|
|
|
|
|
|
}
|
2026-05-24 23:00:27 +02:00
|
|
|
|
} else {
|
2026-05-24 23:17:48 +02:00
|
|
|
|
int plus_y = cy + (cell_h - line_h) / 2;
|
2026-05-24 23:00:27 +02:00
|
|
|
|
display.drawTextCentered(cx + cell_w / 2, plus_y, "+");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (sel) display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
|
}
|
2026-05-25 08:08:12 +02:00
|
|
|
|
if (_pin_menu.active) _pin_menu.render(display);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
} else if (_page == HomePage::SHUTDOWN) {
|
2026-05-11 15:56:30 +02:00
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
display.setTextSize(1);
|
|
|
|
|
|
if (_shutdown_init) {
|
2026-05-22 18:05:09 +02:00
|
|
|
|
display.drawTextCentered(display.width() / 2, content_y + step, "hibernating...");
|
2025-08-08 20:01:31 +10:00
|
|
|
|
} else {
|
2026-05-22 18:05:09 +02:00
|
|
|
|
display.drawXbm((display.width() - 32) / 2, content_y, power_icon, 32, 32);
|
2026-05-23 14:45:24 +02:00
|
|
|
|
const int text_y = content_y + 32 + 3;
|
|
|
|
|
|
const int lh1 = display.getLineHeight();
|
|
|
|
|
|
if (text_y + lh1 <= display.height()) {
|
|
|
|
|
|
char hib_hint[32];
|
2026-05-24 00:56:43 +02:00
|
|
|
|
snprintf(hib_hint, sizeof(hib_hint), "hibernate:%s", PRESS_LABEL);
|
2026-05-23 14:45:24 +02:00
|
|
|
|
if (display.getTextWidth(hib_hint) < display.width()) {
|
|
|
|
|
|
display.drawTextCentered(display.width() / 2, text_y, hib_hint);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
display.drawTextCentered(display.width() / 2, text_y, "hibernate:");
|
|
|
|
|
|
if (text_y + step + lh1 <= display.height())
|
|
|
|
|
|
display.drawTextCentered(display.width() / 2, text_y + step, PRESS_LABEL);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-08 20:01:31 +10:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-14 17:25:27 +02:00
|
|
|
|
bool auto_adv = _node_prefs && _node_prefs->advert_auto_interval_sec > 0;
|
2026-05-25 09:24:36 +02:00
|
|
|
|
// Any blinking status-bar indicator needs a 1 s refresh to animate evenly.
|
|
|
|
|
|
bool need_blink = auto_adv || _task->trail().isActive();
|
2026-05-24 20:41:25 +02:00
|
|
|
|
if (Features::IS_EINK) {
|
|
|
|
|
|
// slow display: poll every 30 s; inbound msgs force immediate refresh via notify()
|
|
|
|
|
|
return Features::HOME_REFRESH_MS;
|
|
|
|
|
|
}
|
2026-05-12 20:12:54 +02:00
|
|
|
|
if (_page == HomePage::CLOCK) {
|
|
|
|
|
|
bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds;
|
2026-05-25 09:24:36 +02:00
|
|
|
|
return need_blink ? 1000 : (show_sec ? 1000 : 60000);
|
2026-05-12 20:12:54 +02:00
|
|
|
|
}
|
2026-05-25 09:24:36 +02:00
|
|
|
|
return need_blink ? 1000 : 5000;
|
2025-08-08 20:01:31 +10:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool handleInput(char c) override {
|
2026-05-24 23:00:27 +02:00
|
|
|
|
// Favourites grid claims joystick UP/DOWN and inner LEFT/RIGHT; LEFT at the
|
|
|
|
|
|
// left column and RIGHT at the right column fall through to page nav so the
|
|
|
|
|
|
// user can still leave the page sideways.
|
|
|
|
|
|
if (_page == HomePage::FAVOURITES) {
|
2026-05-25 08:08:12 +02:00
|
|
|
|
// Pin picker consumes all input while open.
|
|
|
|
|
|
if (_pin_menu.active) {
|
|
|
|
|
|
auto res = _pin_menu.handleInput(c);
|
|
|
|
|
|
if (res == PopupMenu::SELECTED && _pin_target_slot >= 0) {
|
|
|
|
|
|
int idx = _pin_menu.selectedIndex();
|
|
|
|
|
|
if (idx >= 0 && idx < _pin_count) {
|
|
|
|
|
|
// If this contact is already pinned elsewhere, vacate that slot first.
|
|
|
|
|
|
int existing = _task->findFavouriteSlot(_pin_keys[idx]);
|
|
|
|
|
|
if (existing >= 0 && existing != _pin_target_slot) _task->clearFavouriteSlot(existing);
|
|
|
|
|
|
_task->setFavouriteSlot(_pin_target_slot, _pin_keys[idx]);
|
|
|
|
|
|
the_mesh.savePrefs();
|
|
|
|
|
|
char alert[24];
|
|
|
|
|
|
snprintf(alert, sizeof(alert), "Pinned to slot %d", _pin_target_slot + 1);
|
|
|
|
|
|
_task->showAlert(alert, 800);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (res != PopupMenu::NONE) _pin_target_slot = -1;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-05-24 23:00:27 +02:00
|
|
|
|
DisplayDriver* d = _task->getDisplay();
|
|
|
|
|
|
const int cols = (d && d->isLandscape()) ? 3 : 2;
|
|
|
|
|
|
const int rows = NodePrefs::FAVOURITES_COUNT / cols;
|
|
|
|
|
|
int col = _fav_sel % cols;
|
|
|
|
|
|
int row = _fav_sel / cols;
|
|
|
|
|
|
if ((c == KEY_LEFT || c == KEY_PREV) && col > 0) { _fav_sel--; return true; }
|
|
|
|
|
|
if ((c == KEY_RIGHT || c == KEY_NEXT) && col < cols - 1) { _fav_sel++; return true; }
|
|
|
|
|
|
if (c == KEY_UP && row > 0) { _fav_sel -= cols; return true; }
|
|
|
|
|
|
if (c == KEY_DOWN && row < rows - 1) { _fav_sel += cols; return true; }
|
|
|
|
|
|
if (c == KEY_ENTER) {
|
2026-05-25 07:52:56 +02:00
|
|
|
|
// Filled slot → open the DM directly. Empty slot waits for phase 3
|
|
|
|
|
|
// (mini-picker); for now show the pin hint.
|
|
|
|
|
|
NodePrefs* p = _task->getNodePrefs();
|
|
|
|
|
|
const uint8_t* pfx = (p && _fav_sel < NodePrefs::FAVOURITES_COUNT)
|
|
|
|
|
|
? p->favourite_contacts[_fav_sel] : nullptr;
|
|
|
|
|
|
bool filled = false;
|
|
|
|
|
|
if (pfx) for (uint8_t b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++)
|
|
|
|
|
|
if (pfx[b]) { filled = true; break; }
|
|
|
|
|
|
if (filled) {
|
|
|
|
|
|
for (int idx = 0; ; idx++) {
|
|
|
|
|
|
ContactInfo c2;
|
|
|
|
|
|
if (!the_mesh.getContactByIdx(idx, c2)) break;
|
|
|
|
|
|
if (memcmp(c2.id.pub_key, pfx, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
|
|
|
|
|
|
_task->openContactDM(c2);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
_task->showAlert("Contact not found", 800);
|
|
|
|
|
|
} else {
|
2026-05-25 08:08:12 +02:00
|
|
|
|
// Empty slot → open in-place pin picker.
|
|
|
|
|
|
buildPinPicker(_fav_sel);
|
2026-05-25 07:52:56 +02:00
|
|
|
|
}
|
2026-05-24 23:00:27 +02:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Edge LEFT/RIGHT and unhandled keys fall through to page nav below.
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-03 16:28:58 +10:00
|
|
|
|
if (c == KEY_LEFT || c == KEY_PREV) {
|
2026-05-12 09:51:30 +02:00
|
|
|
|
_page = navPage(_page, -1);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2025-09-03 16:28:58 +10:00
|
|
|
|
if (c == KEY_NEXT || c == KEY_RIGHT) {
|
2026-05-12 09:51:30 +02:00
|
|
|
|
_page = navPage(_page, +1);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
if (_page == HomePage::RECENT) {
|
|
|
|
|
|
_task->showAlert("Recent adverts", 800);
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
|
|
|
|
|
|
if (_task->isSerialEnabled()) { // toggle Bluetooth on/off
|
|
|
|
|
|
_task->disableSerial();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_task->enableSerial();
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
|
2025-09-17 08:53:50 +08:00
|
|
|
|
_task->notify(UIEventType::ack);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
if (the_mesh.advert()) {
|
|
|
|
|
|
_task->showAlert("Advert sent!", 1000);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_task->showAlert("Advert failed..", 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2025-09-28 09:43:28 +02:00
|
|
|
|
#if ENV_INCLUDE_GPS == 1
|
2025-09-23 10:39:43 +02:00
|
|
|
|
if (c == KEY_ENTER && _page == HomePage::GPS) {
|
|
|
|
|
|
_task->toggleGPS();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
2025-09-09 16:32:41 +02:00
|
|
|
|
#if UI_SENSORS_PAGE == 1
|
2025-09-05 15:32:02 +02:00
|
|
|
|
if (c == KEY_ENTER && _page == HomePage::SENSORS) {
|
2026-05-25 19:26:02 +02:00
|
|
|
|
// _task->toggleGPS();
|
2025-09-05 15:35:04 +02:00
|
|
|
|
next_sensors_refresh=0;
|
2025-09-05 15:32:02 +02:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2025-09-09 16:32:41 +02:00
|
|
|
|
#endif
|
2026-05-10 15:18:35 +02:00
|
|
|
|
if (c == KEY_ENTER && _page == HomePage::SETTINGS) {
|
|
|
|
|
|
_task->gotoSettingsScreen();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-05-12 09:40:37 +02:00
|
|
|
|
if (c == KEY_ENTER && _page == HomePage::TOOLS) {
|
|
|
|
|
|
_task->gotoToolsScreen();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-05-10 18:45:35 +02:00
|
|
|
|
if (c == KEY_ENTER && _page == HomePage::QUICK_MSG) {
|
|
|
|
|
|
_task->gotoQuickMsgScreen();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2025-08-08 20:01:31 +10:00
|
|
|
|
if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) {
|
|
|
|
|
|
_shutdown_init = true; // need to wait for button to be released
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-05-14 11:28:36 +02:00
|
|
|
|
if (c == KEY_CONTEXT_MENU && _page == HomePage::CLOCK) {
|
|
|
|
|
|
_task->gotoDashboardConfig();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2025-08-08 20:01:31 +10:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-03-04 23:09:43 +11:00
|
|
|
|
|
2025-06-19 17:26:58 +02:00
|
|
|
|
void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) {
|
2025-03-16 13:42:36 +11:00
|
|
|
|
_display = display;
|
2025-06-19 17:26:58 +02:00
|
|
|
|
_sensors = sensors;
|
2026-05-10 15:18:35 +02:00
|
|
|
|
_node_prefs = node_prefs;
|
|
|
|
|
|
uint32_t aoff = autoOffMillis();
|
|
|
|
|
|
_auto_off = millis() + (aoff > 0 ? aoff : AUTO_OFF_MILLIS);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
|
|
|
|
|
#if defined(PIN_USER_BTN)
|
|
|
|
|
|
user_btn.begin();
|
|
|
|
|
|
#endif
|
2025-09-01 17:11:55 +10:00
|
|
|
|
#if defined(PIN_USER_BTN_ANA)
|
|
|
|
|
|
analog_btn.begin();
|
|
|
|
|
|
#endif
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
2025-03-10 17:11:55 +01:00
|
|
|
|
if (_display != NULL) {
|
|
|
|
|
|
_display->turnOn();
|
|
|
|
|
|
}
|
2025-04-08 22:58:17 +12:00
|
|
|
|
|
2025-05-20 11:52:55 +10:00
|
|
|
|
#ifdef PIN_BUZZER
|
2025-11-20 18:55:39 -08:00
|
|
|
|
buzzer.quiet(_node_prefs->buzzer_quiet);
|
2026-05-12 09:17:37 +02:00
|
|
|
|
buzzer.setVolume(_node_prefs->buzzer_volume);
|
2026-05-10 15:18:35 +02:00
|
|
|
|
buzzer.begin();
|
2025-05-20 11:52:55 +10:00
|
|
|
|
#endif
|
2025-05-27 19:10:56 -07:00
|
|
|
|
|
2025-09-07 15:16:15 +08:00
|
|
|
|
#ifdef PIN_VIBRATION
|
|
|
|
|
|
vibration.begin();
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
2026-05-11 16:52:24 +02:00
|
|
|
|
// Set default quick message if slot 0 is empty (first boot)
|
|
|
|
|
|
if (_node_prefs && _node_prefs->custom_msgs[0][0] == '\0') {
|
|
|
|
|
|
strncpy(_node_prefs->custom_msgs[0], "OK", sizeof(_node_prefs->custom_msgs[0]) - 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-06-04 21:33:48 +10:00
|
|
|
|
ui_started_at = millis();
|
2025-08-08 20:01:31 +10:00
|
|
|
|
_alert_expiry = 0;
|
2026-05-10 15:18:35 +02:00
|
|
|
|
_batt_mv = AbstractUITask::getBattMilliVolts(); // seed EMA with first reading
|
2026-06-03 08:23:11 +02:00
|
|
|
|
|
|
|
|
|
|
// Load persisted waypoints (table survives reboots, unlike the RAM trail).
|
|
|
|
|
|
{
|
|
|
|
|
|
DataStore* ds = the_mesh.getDataStore();
|
|
|
|
|
|
if (ds) {
|
|
|
|
|
|
File f = ds->openRead("/waypoints");
|
|
|
|
|
|
if (f) { _waypoints.readFrom(f); f.close(); }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-29 21:49:48 +02:00
|
|
|
|
|
|
|
|
|
|
// Initialize ping state
|
|
|
|
|
|
_ping_active = false;
|
|
|
|
|
|
_ping_tag = 0;
|
|
|
|
|
|
_ping_sent_ms = 0;
|
|
|
|
|
|
_ping_snr_out_x4 = 0;
|
|
|
|
|
|
_ping_snr_back_x4 = 0;
|
|
|
|
|
|
_ping_rtt_ms = 0;
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
|
|
|
|
|
splash = new SplashScreen(this);
|
|
|
|
|
|
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
|
2026-05-10 15:18:35 +02:00
|
|
|
|
settings = new SettingsScreen(this);
|
2026-05-10 18:45:35 +02:00
|
|
|
|
quick_msg = new QuickMsgScreen(this);
|
2026-05-12 09:40:37 +02:00
|
|
|
|
tools_screen = new ToolsScreen(this);
|
|
|
|
|
|
ringtone_edit = new RingtoneEditorScreen(this, node_prefs);
|
2026-05-12 11:47:33 +02:00
|
|
|
|
bot_screen = new BotScreen(this, node_prefs);
|
2026-05-14 11:28:36 +02:00
|
|
|
|
nearby_screen = new NearbyScreen(this);
|
|
|
|
|
|
dashboard_config = new DashboardConfigScreen(this, node_prefs);
|
2026-05-14 16:08:33 +02:00
|
|
|
|
auto_advert_screen = new AutoAdvertScreen(this, node_prefs);
|
2026-05-25 09:10:40 +02:00
|
|
|
|
trail_screen = new TrailScreen(this, &_trail);
|
2026-06-03 14:07:53 +02:00
|
|
|
|
compass_screen = new CompassScreen(this);
|
2026-05-10 15:18:35 +02:00
|
|
|
|
applyBrightness();
|
2026-05-20 09:29:38 +02:00
|
|
|
|
applyFont();
|
2026-05-20 21:53:40 +02:00
|
|
|
|
applyRotation();
|
2026-05-24 10:22:03 +02:00
|
|
|
|
applyFullRefreshInterval();
|
2026-05-22 23:50:21 +02:00
|
|
|
|
setCurrScreen(splash);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 22:12:29 +02:00
|
|
|
|
void UITask::gotoSettingsScreen() {
|
|
|
|
|
|
((SettingsScreen*)settings)->markClean();
|
|
|
|
|
|
setCurrScreen(settings);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 09:40:37 +02:00
|
|
|
|
void UITask::gotoToolsScreen() {
|
|
|
|
|
|
setCurrScreen(tools_screen);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 11:43:38 +02:00
|
|
|
|
void UITask::gotoRingtoneEditor(int slot) {
|
|
|
|
|
|
((RingtoneEditorScreen*)ringtone_edit)->enter(slot);
|
2026-05-12 09:40:37 +02:00
|
|
|
|
setCurrScreen(ringtone_edit);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 11:47:33 +02:00
|
|
|
|
void UITask::gotoBotScreen() {
|
|
|
|
|
|
((BotScreen*)bot_screen)->enter();
|
|
|
|
|
|
setCurrScreen(bot_screen);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 11:28:36 +02:00
|
|
|
|
void UITask::gotoNearbyScreen() {
|
|
|
|
|
|
((NearbyScreen*)nearby_screen)->enter();
|
|
|
|
|
|
setCurrScreen(nearby_screen);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void UITask::gotoDashboardConfig() {
|
|
|
|
|
|
((DashboardConfigScreen*)dashboard_config)->enter();
|
|
|
|
|
|
setCurrScreen(dashboard_config);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 09:10:40 +02:00
|
|
|
|
void UITask::gotoTrailScreen() {
|
|
|
|
|
|
((TrailScreen*)trail_screen)->enter();
|
|
|
|
|
|
setCurrScreen(trail_screen);
|
2026-05-25 08:22:12 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 14:07:53 +02:00
|
|
|
|
void UITask::gotoCompassScreen() {
|
|
|
|
|
|
((CompassScreen*)compass_screen)->enter();
|
|
|
|
|
|
setCurrScreen(compass_screen);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 16:08:33 +02:00
|
|
|
|
void UITask::gotoAutoAdvertScreen() {
|
|
|
|
|
|
((AutoAdvertScreen*)auto_advert_screen)->enter();
|
|
|
|
|
|
setCurrScreen(auto_advert_screen);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 21:49:48 +02:00
|
|
|
|
// Public method to handle ping result callback
|
|
|
|
|
|
void UITask::handlePingResult(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms) {
|
|
|
|
|
|
if (_ping_active && _ping_tag == tag) {
|
|
|
|
|
|
_ping_snr_out_x4 = snr_out_x4;
|
|
|
|
|
|
_ping_snr_back_x4 = snr_back_x4;
|
|
|
|
|
|
_ping_rtt_ms = rtt_ms;
|
|
|
|
|
|
// Release the in-flight slot immediately; the UI keeps the result values.
|
|
|
|
|
|
clearPing();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Static ping callback (for MyMesh)
|
|
|
|
|
|
static void onPingResult(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms) {
|
|
|
|
|
|
AbstractUITask* ui = the_mesh.getUITask();
|
|
|
|
|
|
if (ui) {
|
|
|
|
|
|
UITask* task = static_cast<UITask*>(ui);
|
|
|
|
|
|
task->handlePingResult(tag, snr_out_x4, snr_back_x4, rtt_ms);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void UITask::clearPing() {
|
|
|
|
|
|
if (_ping_tag != 0) {
|
|
|
|
|
|
the_mesh.clearPingResult(_ping_tag);
|
|
|
|
|
|
}
|
|
|
|
|
|
_ping_active = false;
|
|
|
|
|
|
_ping_tag = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool UITask::startPing(const uint8_t* pub_key) {
|
|
|
|
|
|
if (_ping_active || !pub_key) return false;
|
|
|
|
|
|
if (_node_prefs && _node_prefs->path_hash_mode > 1) {
|
|
|
|
|
|
showAlert("Ping not supported with 3-byte path hashes", 3000);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_ping_active = true;
|
|
|
|
|
|
_ping_tag = 0;
|
|
|
|
|
|
_ping_sent_ms = millis();
|
|
|
|
|
|
_ping_snr_out_x4 = 0;
|
|
|
|
|
|
_ping_snr_back_x4 = 0;
|
|
|
|
|
|
_ping_rtt_ms = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Always install the callback before sending so the response cannot race it.
|
|
|
|
|
|
the_mesh.setPingCallback(onPingResult, NULL);
|
|
|
|
|
|
_ping_tag = the_mesh.sendPing(pub_key, _node_prefs ? _node_prefs->path_hash_mode + 1 : 1);
|
|
|
|
|
|
if (_ping_tag == 0) {
|
|
|
|
|
|
clearPing();
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 09:40:37 +02:00
|
|
|
|
void UITask::playMelody(const char* melody) {
|
|
|
|
|
|
#ifdef PIN_BUZZER
|
|
|
|
|
|
buzzer.playForced(melody);
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void UITask::stopMelody() {
|
|
|
|
|
|
#ifdef PIN_BUZZER
|
|
|
|
|
|
buzzer.stop();
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool UITask::isMelodyPlaying() {
|
|
|
|
|
|
#ifdef PIN_BUZZER
|
|
|
|
|
|
return buzzer.isPlaying();
|
|
|
|
|
|
#else
|
|
|
|
|
|
return false;
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 18:45:35 +02:00
|
|
|
|
void UITask::gotoQuickMsgScreen() {
|
|
|
|
|
|
((QuickMsgScreen*)quick_msg)->reset();
|
|
|
|
|
|
setCurrScreen(quick_msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 07:52:56 +02:00
|
|
|
|
void UITask::openContactDM(const ContactInfo& ci) {
|
|
|
|
|
|
((QuickMsgScreen*)quick_msg)->reset();
|
|
|
|
|
|
((QuickMsgScreen*)quick_msg)->enterDM(ci);
|
|
|
|
|
|
setCurrScreen(quick_msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 11:38:12 +02:00
|
|
|
|
void UITask::shareToMessage(const char* text) {
|
|
|
|
|
|
((QuickMsgScreen*)quick_msg)->startShare(text);
|
|
|
|
|
|
setCurrScreen(quick_msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 08:08:12 +02:00
|
|
|
|
int UITask::getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const {
|
|
|
|
|
|
return ((QuickMsgScreen*)quick_msg)->getRecentDMContacts(out, max);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 15:56:30 +02:00
|
|
|
|
void UITask::addChannelMsg(uint8_t channel_idx, const char* text) {
|
2026-05-11 18:56:26 +02:00
|
|
|
|
_last_notif_ch_idx = (int)channel_idx;
|
2026-05-11 15:56:30 +02:00
|
|
|
|
((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 19:11:19 +02:00
|
|
|
|
int UITask::getChannelUnreadCount() const {
|
|
|
|
|
|
return ((QuickMsgScreen*)quick_msg)->getTotalChannelUnread();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 10:25:55 +02:00
|
|
|
|
void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) {
|
|
|
|
|
|
((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int UITask::getDMUnreadTotal() const {
|
|
|
|
|
|
int total = 0;
|
|
|
|
|
|
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++)
|
|
|
|
|
|
total += _dm_unread_table[i].count;
|
|
|
|
|
|
return total;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-08 20:01:31 +10:00
|
|
|
|
void UITask::showAlert(const char* text, int duration_millis) {
|
2026-05-12 08:19:27 +02:00
|
|
|
|
snprintf(_alert, sizeof(_alert), "%s", text);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
_alert_expiry = millis() + duration_millis;
|
2025-05-20 11:52:55 +10:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 11:43:38 +02:00
|
|
|
|
static void buildMelodyFromPrefs(const NodePrefs* p, int slot, char* buf, int size) {
|
2026-05-23 20:03:45 +02:00
|
|
|
|
const uint8_t* notes = (slot == 2) ? p->ringtone2_notes : p->ringtone_notes;
|
|
|
|
|
|
uint8_t len = (slot == 2) ? p->ringtone2_len : p->ringtone_len;
|
|
|
|
|
|
uint8_t bpm_i = (slot == 2) ? p->ringtone2_bpm_idx : p->ringtone_bpm_idx;
|
|
|
|
|
|
NodePrefs::buildRTTTLString(notes, len, bpm_i, buf, size);
|
2026-05-15 11:43:38 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-17 08:53:50 +08:00
|
|
|
|
void UITask::notify(UIEventType t) {
|
2025-05-20 11:52:55 +10:00
|
|
|
|
#if defined(PIN_BUZZER)
|
2025-09-17 08:53:50 +08:00
|
|
|
|
switch(t){
|
2026-05-13 10:48:42 +02:00
|
|
|
|
case UIEventType::contactMessage: {
|
|
|
|
|
|
bool play = false;
|
|
|
|
|
|
bool force = false;
|
|
|
|
|
|
if (_last_notif_dm_valid && _node_prefs) {
|
|
|
|
|
|
uint8_t state = 0;
|
|
|
|
|
|
for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) {
|
|
|
|
|
|
if (_node_prefs->dm_notif[i].state &&
|
|
|
|
|
|
memcmp(_node_prefs->dm_notif[i].prefix, _last_notif_dm_prefix, 4) == 0) {
|
|
|
|
|
|
state = _node_prefs->dm_notif[i].state; break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (state == 2) { play = true; force = true; } // force-on
|
|
|
|
|
|
else if (state == 1) { /* muted */ }
|
|
|
|
|
|
else { play = !buzzer.isQuiet(); } // default: follow global
|
|
|
|
|
|
} else {
|
|
|
|
|
|
play = !buzzer.isQuiet();
|
|
|
|
|
|
}
|
|
|
|
|
|
_last_notif_dm_valid = false;
|
|
|
|
|
|
if (play) {
|
2026-05-15 11:43:38 +02:00
|
|
|
|
int slot = _node_prefs ? (int)_node_prefs->notif_melody_dm : 0;
|
|
|
|
|
|
if (_node_prefs) {
|
|
|
|
|
|
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++)
|
|
|
|
|
|
if (_node_prefs->dm_melody[i].slot &&
|
|
|
|
|
|
memcmp(_node_prefs->dm_melody[i].prefix, _last_notif_dm_prefix, 4) == 0)
|
|
|
|
|
|
{ slot = _node_prefs->dm_melody[i].slot; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
bool custom_played = false;
|
2026-06-07 09:13:02 +02:00
|
|
|
|
if (slot == 3) {
|
|
|
|
|
|
custom_played = true; // explicit silence
|
|
|
|
|
|
} else if (slot > 0 && _node_prefs) {
|
2026-05-23 20:03:45 +02:00
|
|
|
|
if (buzzer.isPlaying()) buzzer.stop(); // stop before overwriting _notif_mel_buf
|
2026-05-15 11:43:38 +02:00
|
|
|
|
buildMelodyFromPrefs(_node_prefs, slot, _notif_mel_buf, sizeof(_notif_mel_buf));
|
|
|
|
|
|
if (_notif_mel_buf[0]) {
|
|
|
|
|
|
if (force) buzzer.playForced(_notif_mel_buf); else buzzer.play(_notif_mel_buf);
|
|
|
|
|
|
custom_played = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!custom_played) {
|
|
|
|
|
|
if (force) buzzer.playForced("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7");
|
|
|
|
|
|
else buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7");
|
|
|
|
|
|
}
|
2026-05-13 10:48:42 +02:00
|
|
|
|
}
|
2025-05-20 19:09:49 +12:00
|
|
|
|
break;
|
2026-05-13 10:48:42 +02:00
|
|
|
|
}
|
2026-05-11 18:56:26 +02:00
|
|
|
|
case UIEventType::channelMessage: {
|
2026-05-11 21:17:31 +02:00
|
|
|
|
bool play = false;
|
|
|
|
|
|
bool force = false;
|
2026-05-15 15:34:10 +02:00
|
|
|
|
if (_last_notif_ch_idx >= 0 && _last_notif_ch_idx < 64 && _node_prefs) {
|
2026-05-11 18:56:26 +02:00
|
|
|
|
uint64_t mask = 1ULL << _last_notif_ch_idx;
|
|
|
|
|
|
if (_node_prefs->ch_notif_override & mask) {
|
2026-05-15 11:43:38 +02:00
|
|
|
|
if (!(_node_prefs->ch_notif_muted & mask)) { play = true; force = true; }
|
2026-05-11 18:56:26 +02:00
|
|
|
|
} else {
|
2026-05-15 11:43:38 +02:00
|
|
|
|
play = !buzzer.isQuiet();
|
2026-05-11 18:56:26 +02:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
play = !buzzer.isQuiet();
|
|
|
|
|
|
}
|
2026-05-11 21:17:31 +02:00
|
|
|
|
if (play) {
|
2026-05-15 11:43:38 +02:00
|
|
|
|
int slot = _node_prefs ? (int)_node_prefs->notif_melody_ch : 0;
|
2026-05-15 15:34:10 +02:00
|
|
|
|
if (_last_notif_ch_idx >= 0 && _last_notif_ch_idx < 64 && _node_prefs) {
|
2026-05-15 11:43:38 +02:00
|
|
|
|
uint64_t mask = 1ULL << _last_notif_ch_idx;
|
|
|
|
|
|
if (_node_prefs->ch_notif_melody_set & mask)
|
|
|
|
|
|
slot = (_node_prefs->ch_notif_melody_2 & mask) ? 2 : 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
bool custom_played = false;
|
2026-06-07 09:13:02 +02:00
|
|
|
|
if (slot == 3) {
|
|
|
|
|
|
custom_played = true; // explicit silence
|
|
|
|
|
|
} else if (slot > 0 && _node_prefs) {
|
2026-05-23 20:03:45 +02:00
|
|
|
|
if (buzzer.isPlaying()) buzzer.stop(); // stop before overwriting _notif_mel_buf
|
2026-05-15 11:43:38 +02:00
|
|
|
|
buildMelodyFromPrefs(_node_prefs, slot, _notif_mel_buf, sizeof(_notif_mel_buf));
|
|
|
|
|
|
if (_notif_mel_buf[0]) {
|
|
|
|
|
|
if (force) buzzer.playForced(_notif_mel_buf); else buzzer.play(_notif_mel_buf);
|
|
|
|
|
|
custom_played = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!custom_played) {
|
|
|
|
|
|
if (force) buzzer.playForced("kerplop:d=16,o=6,b=120:32g#,32c#");
|
|
|
|
|
|
else buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#");
|
|
|
|
|
|
}
|
2026-05-11 21:17:31 +02:00
|
|
|
|
}
|
2026-05-15 11:43:38 +02:00
|
|
|
|
_last_notif_ch_idx = -1;
|
2025-05-23 17:58:13 +12:00
|
|
|
|
break;
|
2026-05-11 18:56:26 +02:00
|
|
|
|
}
|
2026-06-07 10:53:17 +02:00
|
|
|
|
case UIEventType::advertReceived:
|
|
|
|
|
|
case UIEventType::advertReceivedFlood:
|
|
|
|
|
|
case UIEventType::advertReceivedZeroHop: {
|
|
|
|
|
|
bool is_flood = (t == UIEventType::advertReceivedFlood);
|
|
|
|
|
|
if (_node_prefs && _node_prefs->advert_sound_scope == ADVERT_SOUND_SCOPE_ZERO_HOP && is_flood) {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-06-04 08:46:15 +02:00
|
|
|
|
if (!buzzer.isQuiet()) {
|
|
|
|
|
|
int slot = _node_prefs ? (int)_node_prefs->notif_melody_ad : 0;
|
|
|
|
|
|
bool custom_played = false;
|
2026-06-07 09:13:02 +02:00
|
|
|
|
if (slot == 3) {
|
|
|
|
|
|
custom_played = true; // explicit silence
|
|
|
|
|
|
} else if (slot > 0 && _node_prefs) {
|
2026-06-04 08:46:15 +02:00
|
|
|
|
if (buzzer.isPlaying()) buzzer.stop(); // stop before overwriting _notif_mel_buf
|
|
|
|
|
|
buildMelodyFromPrefs(_node_prefs, slot, _notif_mel_buf, sizeof(_notif_mel_buf));
|
|
|
|
|
|
if (_notif_mel_buf[0]) {
|
|
|
|
|
|
buzzer.play(_notif_mel_buf);
|
|
|
|
|
|
custom_played = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!custom_played) {
|
|
|
|
|
|
buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2025-05-30 22:55:53 -07:00
|
|
|
|
case UIEventType::ack:
|
2025-05-30 22:58:30 -07:00
|
|
|
|
buzzer.play("ack:d=32,o=8,b=120:c");
|
2025-05-30 22:55:53 -07:00
|
|
|
|
break;
|
2025-05-20 19:33:21 +12:00
|
|
|
|
case UIEventType::roomMessage:
|
|
|
|
|
|
case UIEventType::none:
|
2025-05-20 19:09:49 +12:00
|
|
|
|
default:
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2025-05-20 11:52:55 +10:00
|
|
|
|
#endif
|
2025-03-10 17:11:55 +01:00
|
|
|
|
|
2025-09-07 15:16:15 +08:00
|
|
|
|
#ifdef PIN_VIBRATION
|
2025-09-17 08:53:50 +08:00
|
|
|
|
// Trigger vibration for all UI events except none
|
|
|
|
|
|
if (t != UIEventType::none) {
|
|
|
|
|
|
vibration.trigger();
|
|
|
|
|
|
}
|
2025-09-07 15:16:15 +08:00
|
|
|
|
#endif
|
2025-03-10 17:11:55 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-07 15:16:15 +08:00
|
|
|
|
|
2025-03-10 17:11:55 +01:00
|
|
|
|
void UITask::msgRead(int msgcount) {
|
|
|
|
|
|
_msgcount = msgcount;
|
|
|
|
|
|
if (msgcount == 0) {
|
2026-05-11 19:27:16 +02:00
|
|
|
|
_room_unread = 0;
|
2026-05-29 11:12:30 +02:00
|
|
|
|
memset(_dm_unread_table, 0, sizeof(_dm_unread_table));
|
2026-05-13 10:36:37 +02:00
|
|
|
|
((QuickMsgScreen*)quick_msg)->clearAllChannelUnread();
|
2025-03-10 17:11:55 +01:00
|
|
|
|
}
|
2025-03-04 23:09:43 +11:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 10:25:55 +02:00
|
|
|
|
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type, const uint8_t* pub_key) {
|
2025-03-10 17:11:55 +01:00
|
|
|
|
_msgcount = msgcount;
|
2026-05-11 19:34:07 +02:00
|
|
|
|
if (contact_type == ADV_TYPE_ROOM && _room_unread < _msgcount) _room_unread++;
|
2026-05-13 10:25:55 +02:00
|
|
|
|
if (contact_type == ADV_TYPE_CHAT && pub_key != nullptr) {
|
2026-05-13 10:48:42 +02:00
|
|
|
|
memcpy(_last_notif_dm_prefix, pub_key, 4);
|
|
|
|
|
|
_last_notif_dm_valid = true;
|
2026-05-13 10:25:55 +02:00
|
|
|
|
int slot = -1, empty_slot = -1;
|
|
|
|
|
|
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) {
|
|
|
|
|
|
if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0) { slot = i; break; }
|
|
|
|
|
|
if (empty_slot < 0 && _dm_unread_table[i].count == 0) empty_slot = i;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (slot >= 0) {
|
|
|
|
|
|
if (_dm_unread_table[slot].count < 99) _dm_unread_table[slot].count++;
|
|
|
|
|
|
} else if (empty_slot >= 0) {
|
|
|
|
|
|
memcpy(_dm_unread_table[empty_slot].prefix, pub_key, 4);
|
|
|
|
|
|
_dm_unread_table[empty_slot].count = 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-03-10 17:11:55 +01:00
|
|
|
|
|
2026-05-11 17:19:03 +02:00
|
|
|
|
char alert_buf[80];
|
|
|
|
|
|
snprintf(alert_buf, sizeof(alert_buf), "Msg: %.20s", from_name);
|
|
|
|
|
|
showAlert(alert_buf, 3000);
|
2025-03-04 23:09:43 +11:00
|
|
|
|
|
2026-05-17 09:55:51 +02:00
|
|
|
|
if (_display != NULL && !_locked) {
|
2026-06-05 12:42:14 +02:00
|
|
|
|
if (!_display->isOn() && !isClientConnected()) { // wake for the msg unless an app (BLE/USB) is already showing it
|
2025-11-03 21:25:31 +01:00
|
|
|
|
_display->turnOn();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (_display->isOn()) {
|
2026-05-11 17:19:03 +02:00
|
|
|
|
uint32_t aoff = autoOffMillis();
|
|
|
|
|
|
if (aoff > 0) _auto_off = millis() + aoff;
|
|
|
|
|
|
_next_refresh = 100;
|
2025-11-03 21:25:31 +01:00
|
|
|
|
}
|
2025-03-10 17:11:55 +01:00
|
|
|
|
}
|
2025-03-04 23:09:43 +11:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-03-10 17:11:55 +01:00
|
|
|
|
void UITask::userLedHandler() {
|
|
|
|
|
|
#ifdef PIN_STATUS_LED
|
2026-05-23 20:03:45 +02:00
|
|
|
|
unsigned long cur_time = millis();
|
2025-09-03 17:22:11 +02:00
|
|
|
|
if (cur_time > next_led_change) {
|
|
|
|
|
|
if (led_state == 0) {
|
|
|
|
|
|
led_state = 1;
|
2025-03-10 17:11:55 +01:00
|
|
|
|
if (_msgcount > 0) {
|
2025-09-03 17:22:11 +02:00
|
|
|
|
last_led_increment = LED_ON_MSG_MILLIS;
|
2025-03-10 17:11:55 +01:00
|
|
|
|
} else {
|
2025-09-03 17:22:11 +02:00
|
|
|
|
last_led_increment = LED_ON_MILLIS;
|
2025-03-10 17:11:55 +01:00
|
|
|
|
}
|
2025-09-03 17:22:11 +02:00
|
|
|
|
next_led_change = cur_time + last_led_increment;
|
2025-03-10 17:11:55 +01:00
|
|
|
|
} else {
|
2025-09-03 17:22:11 +02:00
|
|
|
|
led_state = 0;
|
|
|
|
|
|
next_led_change = cur_time + LED_CYCLE_MILLIS - last_led_increment;
|
2025-03-04 23:09:43 +11:00
|
|
|
|
}
|
2025-11-20 10:58:14 +01:00
|
|
|
|
digitalWrite(PIN_STATUS_LED, led_state == LED_STATE_ON);
|
2025-03-04 23:09:43 +11:00
|
|
|
|
}
|
2025-03-10 17:11:55 +01:00
|
|
|
|
#endif
|
|
|
|
|
|
}
|
2025-03-04 23:09:43 +11:00
|
|
|
|
|
2025-08-08 20:01:31 +10:00
|
|
|
|
void UITask::setCurrScreen(UIScreen* c) {
|
|
|
|
|
|
curr = c;
|
2025-09-03 17:22:11 +02:00
|
|
|
|
_next_refresh = 100;
|
2025-08-08 20:01:31 +10:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-04 21:45:42 +02:00
|
|
|
|
/*
|
|
|
|
|
|
hardware-agnostic pre-shutdown activity should be done here
|
2025-05-27 11:07:51 +12:00
|
|
|
|
*/
|
|
|
|
|
|
void UITask::shutdown(bool restart){
|
2026-05-10 18:23:25 +02:00
|
|
|
|
the_mesh.saveRTCTime();
|
2025-05-27 11:07:51 +12:00
|
|
|
|
|
|
|
|
|
|
#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
|
2026-05-15 15:34:10 +02:00
|
|
|
|
while (buzzer.isPlaying() && (millis() - buzzer_timer) < 2500)
|
2025-05-27 11:07:51 +12:00
|
|
|
|
buzzer.loop();
|
|
|
|
|
|
|
|
|
|
|
|
#endif // PIN_BUZZER
|
|
|
|
|
|
|
2025-08-08 20:01:31 +10:00
|
|
|
|
if (restart) {
|
2025-05-27 11:07:51 +12:00
|
|
|
|
_board->reboot();
|
2025-08-08 20:01:31 +10:00
|
|
|
|
} else {
|
|
|
|
|
|
_display->turnOff();
|
2025-11-22 02:06:44 +01:00
|
|
|
|
radio_driver.powerOff();
|
2025-05-27 11:07:51 +12:00
|
|
|
|
_board->powerOff();
|
2025-08-08 20:01:31 +10:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool UITask::isButtonPressed() const {
|
|
|
|
|
|
#ifdef PIN_USER_BTN
|
|
|
|
|
|
return user_btn.isPressed();
|
|
|
|
|
|
#else
|
|
|
|
|
|
return false;
|
|
|
|
|
|
#endif
|
2025-05-27 11:07:51 +12:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 18:32:49 +02:00
|
|
|
|
static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv,
|
2026-05-28 22:57:50 +02:00
|
|
|
|
uint16_t low_batt_mv, CayenneLPP* lpp = nullptr) {
|
2026-05-17 10:04:28 +02:00
|
|
|
|
val[0] = '\0';
|
|
|
|
|
|
switch (field) {
|
|
|
|
|
|
case DASH_NONE: return;
|
2026-05-27 22:55:50 +02:00
|
|
|
|
case DASH_BATT_V:
|
2026-05-17 10:04:28 +02:00
|
|
|
|
if (batt_mv > 0) snprintf(val, val_len, "%u.%02uV", batt_mv/1000, (batt_mv%1000)/10);
|
|
|
|
|
|
else strcpy(val, "--");
|
|
|
|
|
|
return;
|
2026-05-27 22:55:50 +02:00
|
|
|
|
case DASH_BATT_PCT:
|
2026-05-28 22:57:50 +02:00
|
|
|
|
if (batt_mv > 0) snprintf(val, val_len, "%d%%", battMvToPercent(batt_mv, low_batt_mv));
|
|
|
|
|
|
else strcpy(val, "--");
|
2026-05-27 22:55:50 +02:00
|
|
|
|
return;
|
2026-05-17 10:04:28 +02:00
|
|
|
|
case DASH_NODES:
|
|
|
|
|
|
snprintf(val, val_len, "%d nodes", the_mesh.getNumContacts());
|
|
|
|
|
|
return;
|
|
|
|
|
|
#if ENV_INCLUDE_GPS == 1
|
|
|
|
|
|
case DASH_GPS: {
|
|
|
|
|
|
LocationProvider* loc = sensors.getLocationProvider();
|
|
|
|
|
|
if (loc && loc->isValid())
|
|
|
|
|
|
snprintf(val, val_len, "%.2f %.2f",
|
|
|
|
|
|
loc->getLatitude()/1000000.0f, loc->getLongitude()/1000000.0f);
|
|
|
|
|
|
else strcpy(val, "no fix");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
default: break;
|
|
|
|
|
|
}
|
2026-05-17 18:32:49 +02:00
|
|
|
|
// LPP sensor fields
|
2026-05-17 10:04:28 +02:00
|
|
|
|
uint8_t lpp_type = 0;
|
|
|
|
|
|
switch (field) {
|
|
|
|
|
|
case DASH_TEMP: lpp_type = LPP_TEMPERATURE; break;
|
|
|
|
|
|
case DASH_HUM: lpp_type = LPP_RELATIVE_HUMIDITY; break;
|
|
|
|
|
|
case DASH_PRES: lpp_type = LPP_BAROMETRIC_PRESSURE; break;
|
|
|
|
|
|
case DASH_ALT: lpp_type = LPP_ALTITUDE; break;
|
|
|
|
|
|
case DASH_LUX: lpp_type = LPP_LUMINOSITY; break;
|
|
|
|
|
|
case DASH_CO2: lpp_type = LPP_CONCENTRATION; break;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (lpp_type) {
|
2026-05-23 20:03:45 +02:00
|
|
|
|
if (!lpp) { static CayenneLPP s_lpp(200); s_lpp.reset(); sensors.querySensors(0xFF, s_lpp); lpp = &s_lpp; }
|
2026-05-17 18:32:49 +02:00
|
|
|
|
LPPReader r(lpp->getBuffer(), lpp->getSize());
|
2026-05-17 10:04:28 +02:00
|
|
|
|
uint8_t ch, type;
|
|
|
|
|
|
while (r.readHeader(ch, type)) {
|
|
|
|
|
|
if (type == lpp_type) {
|
|
|
|
|
|
float v;
|
|
|
|
|
|
switch (lpp_type) {
|
|
|
|
|
|
case LPP_TEMPERATURE: r.readTemperature(v); snprintf(val, val_len, "%.1f\xf8""C", v); return;
|
|
|
|
|
|
case LPP_RELATIVE_HUMIDITY: r.readRelativeHumidity(v); snprintf(val, val_len, "%.0f%%", v); return;
|
|
|
|
|
|
case LPP_BAROMETRIC_PRESSURE: r.readPressure(v); snprintf(val, val_len, "%.0fhPa", v); return;
|
|
|
|
|
|
case LPP_ALTITUDE: r.readAltitude(v); snprintf(val, val_len, "%.0fm", v); return;
|
|
|
|
|
|
case LPP_LUMINOSITY: r.readLuminosity(v); snprintf(val, val_len, "%.0flux", v); return;
|
|
|
|
|
|
case LPP_CONCENTRATION: r.readConcentration(v); snprintf(val, val_len, "%.0fppm", v); return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
r.skipData(type);
|
|
|
|
|
|
}
|
|
|
|
|
|
strcpy(val, "--");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-03-10 17:11:55 +01:00
|
|
|
|
void UITask::loop() {
|
2025-08-08 20:01:31 +10:00
|
|
|
|
char c = 0;
|
2025-10-16 17:33:22 +11:00
|
|
|
|
#if UI_HAS_JOYSTICK
|
2026-05-23 15:20:40 +02:00
|
|
|
|
uint8_t joy_rot = _node_prefs ? _node_prefs->joystick_rotation : JOYSTICK_ROTATION;
|
2025-08-08 20:01:31 +10:00
|
|
|
|
int ev = user_btn.check();
|
|
|
|
|
|
if (ev == BUTTON_EVENT_CLICK) {
|
2026-05-17 10:09:54 +02:00
|
|
|
|
if (back_btn.isPressed()) {
|
|
|
|
|
|
// Enter clicked while Back is held — lock/unlock sequence
|
2026-05-17 11:17:59 +02:00
|
|
|
|
if (_display && !_display->isOn()) {
|
|
|
|
|
|
_display->turnOn(); // turn on display so hints are visible
|
|
|
|
|
|
}
|
|
|
|
|
|
_lock_wake_until = millis() + 5000; // keep display on during sequence
|
2026-05-17 10:09:54 +02:00
|
|
|
|
if (millis() - _lock_seq_ms > 3000) _lock_seq_count = 0; // timeout reset
|
|
|
|
|
|
_lock_seq_count++;
|
|
|
|
|
|
_lock_seq_ms = millis();
|
2026-05-17 11:17:59 +02:00
|
|
|
|
_next_refresh = 0; // update hint immediately on each press
|
2026-05-17 10:09:54 +02:00
|
|
|
|
if (_lock_seq_count >= 3) {
|
|
|
|
|
|
_lock_seq_count = 0;
|
2026-05-17 11:17:59 +02:00
|
|
|
|
_lock_seq_used = true; // suppress Back release click
|
2026-05-17 10:09:54 +02:00
|
|
|
|
_locked = !_locked;
|
|
|
|
|
|
if (_locked) {
|
|
|
|
|
|
_lock_wake_until = millis() + 2000;
|
|
|
|
|
|
} else {
|
2026-05-17 11:17:59 +02:00
|
|
|
|
if (_display && !_display->isOn()) _display->turnOn();
|
2026-05-17 10:09:54 +02:00
|
|
|
|
uint32_t aoff = autoOffMillis();
|
|
|
|
|
|
if (aoff > 0) _auto_off = millis() + aoff;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// eat the Enter — don't pass to curr
|
|
|
|
|
|
} else {
|
|
|
|
|
|
c = checkDisplayOn(KEY_ENTER);
|
|
|
|
|
|
}
|
2025-08-08 20:01:31 +10:00
|
|
|
|
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
2025-10-16 17:33:22 +11:00
|
|
|
|
c = handleLongPress(KEY_ENTER); // REVISIT: could be mapped to different key code
|
2025-08-08 20:01:31 +10:00
|
|
|
|
}
|
2026-05-10 15:18:35 +02:00
|
|
|
|
#if UI_HAS_JOYSTICK_UPDOWN
|
|
|
|
|
|
ev = joystick_up.check();
|
|
|
|
|
|
if (ev == BUTTON_EVENT_CLICK) {
|
2026-05-23 15:20:40 +02:00
|
|
|
|
c = checkDisplayOn(rotateJoystickKey(KEY_UP, joy_rot));
|
2026-05-10 15:18:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
ev = joystick_down.check();
|
|
|
|
|
|
if (ev == BUTTON_EVENT_CLICK) {
|
2026-05-23 15:20:40 +02:00
|
|
|
|
c = checkDisplayOn(rotateJoystickKey(KEY_DOWN, joy_rot));
|
2026-05-10 15:18:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
#endif
|
2025-08-17 19:01:28 +10:00
|
|
|
|
ev = joystick_left.check();
|
|
|
|
|
|
if (ev == BUTTON_EVENT_CLICK) {
|
2026-05-23 15:20:40 +02:00
|
|
|
|
c = checkDisplayOn(rotateJoystickKey(KEY_LEFT, joy_rot));
|
2025-08-17 19:01:28 +10:00
|
|
|
|
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
2026-05-23 15:20:40 +02:00
|
|
|
|
c = handleLongPress(rotateJoystickKey(KEY_LEFT, joy_rot));
|
2025-08-17 19:01:28 +10:00
|
|
|
|
}
|
|
|
|
|
|
ev = joystick_right.check();
|
|
|
|
|
|
if (ev == BUTTON_EVENT_CLICK) {
|
2026-05-23 15:20:40 +02:00
|
|
|
|
c = checkDisplayOn(rotateJoystickKey(KEY_RIGHT, joy_rot));
|
2025-08-17 19:01:28 +10:00
|
|
|
|
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
2026-05-23 15:20:40 +02:00
|
|
|
|
c = handleLongPress(rotateJoystickKey(KEY_RIGHT, joy_rot));
|
2025-08-17 19:01:28 +10:00
|
|
|
|
}
|
2026-05-17 12:17:02 +02:00
|
|
|
|
if (_lock_seq_used && millis() - _lock_seq_ms > 5000) {
|
|
|
|
|
|
_lock_seq_used = false; // safety reset if Back release event was missed
|
|
|
|
|
|
}
|
2025-10-18 13:37:18 +02:00
|
|
|
|
ev = back_btn.check();
|
2026-05-10 15:18:35 +02:00
|
|
|
|
if (ev == BUTTON_EVENT_CLICK) {
|
2026-05-17 11:17:59 +02:00
|
|
|
|
if (_lock_seq_count > 0 || _lock_seq_used) {
|
|
|
|
|
|
// Back released mid-sequence or after completing it — cancel/suppress
|
2026-05-17 09:55:51 +02:00
|
|
|
|
_lock_seq_count = 0;
|
2026-05-17 11:17:59 +02:00
|
|
|
|
_lock_seq_used = false;
|
2026-05-17 10:09:54 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
c = checkDisplayOn(KEY_CANCEL);
|
2026-05-17 09:55:51 +02:00
|
|
|
|
}
|
2026-05-10 15:18:35 +02:00
|
|
|
|
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
2026-05-17 11:17:59 +02:00
|
|
|
|
if (!_locked) c = handleTripleClick(KEY_SELECT);
|
2025-10-18 13:37:18 +02:00
|
|
|
|
}
|
2025-10-16 17:33:22 +11:00
|
|
|
|
#elif defined(PIN_USER_BTN)
|
|
|
|
|
|
int ev = user_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);
|
|
|
|
|
|
}
|
2025-08-17 19:01:28 +10:00
|
|
|
|
#endif
|
2025-09-01 17:11:55 +10:00
|
|
|
|
#if defined(PIN_USER_BTN_ANA)
|
2026-05-23 20:03:45 +02:00
|
|
|
|
if (millis() - _analogue_pin_read_millis > 10) {
|
2026-04-28 21:26:56 -07:00
|
|
|
|
int ev = analog_btn.check();
|
2025-10-31 13:04:59 +00:00
|
|
|
|
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();
|
2025-09-01 17:11:55 +10:00
|
|
|
|
}
|
|
|
|
|
|
#endif
|
2025-11-28 10:33:19 +01:00
|
|
|
|
#if defined(BACKLIGHT_BTN)
|
2025-09-03 17:22:11 +02:00
|
|
|
|
if (millis() > next_backlight_btn_check) {
|
2025-09-02 11:43:48 +02:00
|
|
|
|
bool touch_state = digitalRead(PIN_BUTTON2);
|
2025-11-28 10:33:19 +01:00
|
|
|
|
#if defined(DISP_BACKLIGHT)
|
2025-09-02 11:43:48 +02:00
|
|
|
|
digitalWrite(DISP_BACKLIGHT, !touch_state);
|
2025-11-28 10:33:19 +01:00
|
|
|
|
#elif defined(EXP_PIN_BACKLIGHT)
|
|
|
|
|
|
expander.digitalWrite(EXP_PIN_BACKLIGHT, !touch_state);
|
|
|
|
|
|
#endif
|
2025-09-03 17:22:11 +02:00
|
|
|
|
next_backlight_btn_check = millis() + 300;
|
2025-09-02 11:43:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
#endif
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
2026-05-17 09:55:51 +02:00
|
|
|
|
if (c != 0) {
|
2026-05-17 10:09:54 +02:00
|
|
|
|
if (!_locked && curr) {
|
2026-05-17 09:55:51 +02:00
|
|
|
|
curr->handleInput(c);
|
|
|
|
|
|
{ uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer
|
2026-05-27 17:33:41 +02:00
|
|
|
|
#ifdef PIN_BUZZER
|
|
|
|
|
|
if (buzzer.isPlaying()) {
|
|
|
|
|
|
// Keep the next render at least 300 ms away so the blocking e-ink endFrame()
|
|
|
|
|
|
// doesn't extend the current note. 300 ms covers the slowest note at 120 BPM (1/4).
|
|
|
|
|
|
unsigned long deadline = millis() + 300;
|
|
|
|
|
|
if (_next_refresh < deadline) _next_refresh = deadline;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_next_refresh = 100; // trigger refresh immediately
|
|
|
|
|
|
}
|
|
|
|
|
|
#else
|
2026-05-17 09:55:51 +02:00
|
|
|
|
_next_refresh = 100; // trigger refresh
|
2026-05-27 17:33:41 +02:00
|
|
|
|
#endif
|
2026-05-17 09:55:51 +02:00
|
|
|
|
} else if (_locked) {
|
2026-05-17 14:30:53 +02:00
|
|
|
|
// Locked: eat all keys — wake window is set only when display first turns on
|
2026-05-17 09:55:51 +02:00
|
|
|
|
_next_refresh = 0;
|
|
|
|
|
|
}
|
2025-08-08 20:01:31 +10:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-03-10 17:11:55 +01:00
|
|
|
|
userLedHandler();
|
|
|
|
|
|
|
2025-05-20 11:52:55 +10:00
|
|
|
|
#ifdef PIN_BUZZER
|
2026-05-12 20:12:54 +02:00
|
|
|
|
if (_node_prefs && _node_prefs->buzzer_auto) {
|
2026-06-05 12:42:14 +02:00
|
|
|
|
bool should_quiet = isClientConnected(); // BLE bonded or an open USB port
|
2026-05-12 20:12:54 +02:00
|
|
|
|
if (buzzer.isQuiet() != should_quiet) {
|
|
|
|
|
|
buzzer.quiet(should_quiet);
|
|
|
|
|
|
_next_refresh = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-05-20 11:52:55 +10:00
|
|
|
|
if (buzzer.isPlaying()) buzzer.loop();
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
2025-08-08 20:01:31 +10:00
|
|
|
|
if (curr) curr->poll();
|
2025-03-04 23:09:43 +11:00
|
|
|
|
|
2025-08-08 20:01:31 +10:00
|
|
|
|
if (_display != NULL && _display->isOn()) {
|
2026-05-17 09:55:51 +02:00
|
|
|
|
if (_locked && millis() > _lock_wake_until) {
|
|
|
|
|
|
_display->turnOff();
|
|
|
|
|
|
} else if (_locked && millis() >= _next_refresh) {
|
|
|
|
|
|
_display->startFrame();
|
|
|
|
|
|
// Lock screen: clock + unlock hint popup
|
|
|
|
|
|
uint32_t unix_ts = rtc_clock.getCurrentTime();
|
|
|
|
|
|
_display->setColor(DisplayDriver::LIGHT);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
_display->setTextSize(1);
|
|
|
|
|
|
const int lk_lh = _display->getLineHeight();
|
|
|
|
|
|
const int lk_step = _display->lineStep();
|
2026-05-17 09:55:51 +02:00
|
|
|
|
if (unix_ts < 1000000000UL) {
|
2026-05-22 18:05:09 +02:00
|
|
|
|
_display->drawTextCentered(_display->width() / 2, _display->height() / 2 - lk_step, "No time sync");
|
2026-05-17 09:55:51 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
int8_t tz = _node_prefs ? _node_prefs->tz_offset_hours : 0;
|
|
|
|
|
|
unix_ts += (int32_t)tz * 3600;
|
|
|
|
|
|
time_t t = (time_t)unix_ts;
|
|
|
|
|
|
struct tm* ti = gmtime(&t);
|
|
|
|
|
|
char buf[12];
|
2026-05-22 18:05:09 +02:00
|
|
|
|
const int clk_y = 2;
|
2026-06-02 23:32:27 +02:00
|
|
|
|
bool h12 = _node_prefs && _node_prefs->clock_12h;
|
|
|
|
|
|
int date_y = drawClockTime(*_display, clk_y, ti, h12, /*show_sec*/false);
|
2026-05-17 09:55:51 +02:00
|
|
|
|
_display->setTextSize(1);
|
|
|
|
|
|
static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"};
|
|
|
|
|
|
static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"};
|
2026-05-24 20:35:49 +02:00
|
|
|
|
snprintf(buf, sizeof(buf),"%s %d %s", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon]);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
_display->drawTextCentered(_display->width() / 2, date_y, buf);
|
2026-05-17 10:04:28 +02:00
|
|
|
|
|
|
|
|
|
|
// Two sensor values side by side (dashboard_fields[0] and [1])
|
|
|
|
|
|
if (_node_prefs) {
|
|
|
|
|
|
char v0[20] = "", v1[20] = "";
|
2026-05-17 18:32:49 +02:00
|
|
|
|
CayenneLPP* lpp_ptr = nullptr;
|
|
|
|
|
|
uint8_t f0 = _node_prefs->dashboard_fields[0], f1 = _node_prefs->dashboard_fields[1];
|
|
|
|
|
|
auto isLPP = [](uint8_t f) {
|
|
|
|
|
|
return f==DASH_TEMP||f==DASH_HUM||f==DASH_PRES||f==DASH_ALT||f==DASH_LUX||f==DASH_CO2;
|
|
|
|
|
|
};
|
|
|
|
|
|
if (isLPP(f0) || isLPP(f1)) {
|
2026-05-23 20:03:45 +02:00
|
|
|
|
_dash_lpp.reset(); sensors.querySensors(0xFF, _dash_lpp); lpp_ptr = &_dash_lpp;
|
2026-05-17 18:32:49 +02:00
|
|
|
|
}
|
2026-05-28 22:57:50 +02:00
|
|
|
|
formatDashVal(f0, v0, sizeof(v0), _batt_mv, _node_prefs->low_batt_mv, lpp_ptr);
|
|
|
|
|
|
formatDashVal(f1, v1, sizeof(v1), _batt_mv, _node_prefs->low_batt_mv, lpp_ptr);
|
2026-05-17 10:04:28 +02:00
|
|
|
|
if (v0[0] || v1[0]) {
|
2026-05-22 18:05:09 +02:00
|
|
|
|
int sv_y = date_y + lk_step;
|
2026-05-17 10:04:28 +02:00
|
|
|
|
_display->setColor(DisplayDriver::LIGHT);
|
|
|
|
|
|
if (v0[0] && v1[0]) {
|
2026-05-22 18:05:09 +02:00
|
|
|
|
_display->setCursor(0, sv_y);
|
2026-05-17 10:04:28 +02:00
|
|
|
|
_display->print(v0);
|
|
|
|
|
|
int vw = _display->getTextWidth(v1);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
_display->setCursor(_display->width() - vw, sv_y);
|
2026-05-17 10:04:28 +02:00
|
|
|
|
_display->print(v1);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const char* sv = v0[0] ? v0 : v1;
|
2026-05-22 18:05:09 +02:00
|
|
|
|
_display->drawTextCentered(_display->width() / 2, sv_y, sv);
|
2026-05-17 10:04:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-17 09:55:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
// Hint popup at bottom (like alert style)
|
|
|
|
|
|
_display->setTextSize(1);
|
2026-05-17 10:09:54 +02:00
|
|
|
|
const char* hint = _lock_seq_count == 0 ? "Hold Back + 3xEnter" :
|
|
|
|
|
|
_lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more...";
|
2026-05-17 09:55:51 +02:00
|
|
|
|
int p = 3;
|
2026-05-22 18:05:09 +02:00
|
|
|
|
int hy = _display->height() - lk_lh - p * 2;
|
2026-05-17 09:55:51 +02:00
|
|
|
|
int hw = _display->getTextWidth(hint);
|
|
|
|
|
|
int hx = (_display->width() - hw) / 2;
|
|
|
|
|
|
_display->setColor(DisplayDriver::LIGHT);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
_display->fillRect(hx - p, hy - p, hw + p*2, lk_lh + p*2);
|
2026-05-17 09:55:51 +02:00
|
|
|
|
_display->setColor(DisplayDriver::DARK);
|
|
|
|
|
|
_display->setCursor(hx, hy);
|
|
|
|
|
|
_display->print(hint);
|
|
|
|
|
|
_display->endFrame();
|
2026-05-24 20:41:25 +02:00
|
|
|
|
_next_refresh = millis() + Features::LOCKSCREEN_REFRESH_MS;
|
2026-05-17 09:55:51 +02:00
|
|
|
|
} else if (!_locked && millis() >= _next_refresh && curr) {
|
2025-08-08 20:01:31 +10:00
|
|
|
|
_display->startFrame();
|
|
|
|
|
|
int delay_millis = curr->render(*_display);
|
2026-05-26 08:15:53 +02:00
|
|
|
|
if (millis() < _alert_expiry) { // alert overlay on top of any screen
|
2025-08-08 20:01:31 +10:00
|
|
|
|
_display->setTextSize(1);
|
2026-05-22 23:13:27 +02:00
|
|
|
|
int lh = _display->getLineHeight();
|
|
|
|
|
|
int pad = 3;
|
|
|
|
|
|
int box_h = lh + pad * 2;
|
|
|
|
|
|
int box_w = _display->width() - 8;
|
|
|
|
|
|
int box_x = 4;
|
|
|
|
|
|
int box_y = (_display->height() - box_h) / 2;
|
2025-08-08 20:01:31 +10:00
|
|
|
|
_display->setColor(DisplayDriver::DARK);
|
2026-05-22 23:13:27 +02:00
|
|
|
|
_display->fillRect(box_x, box_y, box_w, box_h);
|
|
|
|
|
|
_display->setColor(DisplayDriver::LIGHT);
|
|
|
|
|
|
_display->drawRect(box_x, box_y, box_w, box_h);
|
|
|
|
|
|
_display->drawTextCentered(_display->width() / 2, box_y + pad, _alert);
|
2025-08-08 20:01:31 +10:00
|
|
|
|
_next_refresh = _alert_expiry; // will need refresh when alert is dismissed
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_next_refresh = millis() + delay_millis;
|
|
|
|
|
|
}
|
|
|
|
|
|
_display->endFrame();
|
2025-03-04 23:09:43 +11:00
|
|
|
|
}
|
2025-09-03 18:17:37 +02:00
|
|
|
|
#if AUTO_OFF_MILLIS > 0
|
2026-05-28 20:02:50 +01:00
|
|
|
|
#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
|
2026-05-17 09:55:51 +02:00
|
|
|
|
if (!_locked && autoOffMillis() > 0 && millis() > _auto_off) {
|
2025-03-04 23:09:43 +11:00
|
|
|
|
_display->turnOff();
|
2026-05-10 18:01:59 +02:00
|
|
|
|
#ifdef PIN_LED
|
|
|
|
|
|
digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power
|
|
|
|
|
|
#endif
|
2026-05-17 09:55:51 +02:00
|
|
|
|
if (_node_prefs && _node_prefs->auto_lock) {
|
|
|
|
|
|
_locked = true;
|
|
|
|
|
|
_lock_wake_until = 0;
|
|
|
|
|
|
}
|
2025-03-04 23:09:43 +11:00
|
|
|
|
}
|
2025-09-03 18:17:37 +02:00
|
|
|
|
#endif
|
2025-03-04 23:09:43 +11:00
|
|
|
|
}
|
2025-08-08 20:01:31 +10:00
|
|
|
|
|
2025-09-07 15:16:15 +08:00
|
|
|
|
#ifdef PIN_VIBRATION
|
|
|
|
|
|
vibration.loop();
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
2025-08-08 20:01:31 +10:00
|
|
|
|
if (millis() > next_batt_chck) {
|
2026-05-10 15:18:35 +02:00
|
|
|
|
uint16_t raw = AbstractUITask::getBattMilliVolts();
|
|
|
|
|
|
if (raw > 0) {
|
|
|
|
|
|
// EMA filter: alpha=0.2 (80% old, 20% new) — smooths ADC noise from uneven load
|
|
|
|
|
|
_batt_mv = (_batt_mv == 0) ? raw : (uint16_t)((_batt_mv * 4u + raw) / 5u);
|
|
|
|
|
|
}
|
|
|
|
|
|
uint16_t low_mv = _node_prefs ? _node_prefs->low_batt_mv : 0;
|
2026-06-06 17:21:41 +02:00
|
|
|
|
// Don't shut down while on external power (charging) — avoids a shutdown loop.
|
|
|
|
|
|
if (low_mv > 0 && _batt_mv > 0 && _batt_mv < low_mv && !board.isExternalPowered()) {
|
2025-08-30 23:09:01 +12:00
|
|
|
|
if (_display != NULL) {
|
|
|
|
|
|
_display->startFrame();
|
2026-05-16 17:47:22 +02:00
|
|
|
|
_display->setTextSize(1);
|
2026-05-10 15:18:35 +02:00
|
|
|
|
_display->setColor(DisplayDriver::LIGHT);
|
2026-05-22 18:05:09 +02:00
|
|
|
|
int mid = _display->height() / 2;
|
|
|
|
|
|
int step = _display->lineStep();
|
|
|
|
|
|
_display->drawTextCentered(_display->width() / 2, mid - step, "Low Battery");
|
|
|
|
|
|
_display->drawTextCentered(_display->width() / 2, mid, "Shutting down");
|
2025-08-30 23:09:01 +12:00
|
|
|
|
_display->endFrame();
|
2026-06-06 17:21:41 +02:00
|
|
|
|
if (_display->isEink() == false) { delay(2000); }
|
2025-08-30 23:09:01 +12:00
|
|
|
|
}
|
2025-08-08 20:01:31 +10:00
|
|
|
|
shutdown();
|
|
|
|
|
|
}
|
|
|
|
|
|
next_batt_chck = millis() + 8000;
|
|
|
|
|
|
}
|
2026-05-25 08:22:12 +02:00
|
|
|
|
|
2026-05-25 09:10:40 +02:00
|
|
|
|
// GPS trail sampling — runs in the background while the trail is
|
2026-05-25 08:22:12 +02:00
|
|
|
|
// active, independent of which screen is shown. Skips silently if no GPS
|
|
|
|
|
|
// fix; min-delta gate inside addPoint() avoids near-stationary spam.
|
2026-05-25 09:10:40 +02:00
|
|
|
|
if (_trail.isActive() && _node_prefs != NULL
|
|
|
|
|
|
&& (int32_t)(millis() - _next_trail_sample_ms) >= 0) {
|
2026-05-25 10:48:03 +02:00
|
|
|
|
_next_trail_sample_ms = millis() + (uint32_t)TrailStore::SAMPLING_SECS * 1000UL;
|
2026-05-25 08:22:12 +02:00
|
|
|
|
LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr;
|
|
|
|
|
|
if (loc && loc->isValid()) {
|
2026-06-04 00:50:42 +02:00
|
|
|
|
uint16_t md = TrailStore::minDeltaMeters(_node_prefs->trail_min_delta_idx,
|
|
|
|
|
|
_node_prefs->units_imperial);
|
2026-05-25 09:10:40 +02:00
|
|
|
|
_trail.addPoint((int32_t)loc->getLatitude(),
|
|
|
|
|
|
(int32_t)loc->getLongitude(),
|
|
|
|
|
|
(uint32_t)rtc_clock.getCurrentTime(), md);
|
2026-05-25 08:22:12 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-03 08:20:59 +02:00
|
|
|
|
|
|
|
|
|
|
// Course-over-ground sampling — every ~1 s regardless of trail state, so the
|
|
|
|
|
|
// heading is available to navigation even when not recording a trail.
|
|
|
|
|
|
if ((int32_t)(millis() - _next_cog_sample_ms) >= 0) {
|
|
|
|
|
|
_next_cog_sample_ms = millis() + 1000UL;
|
|
|
|
|
|
LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr;
|
|
|
|
|
|
if (loc && loc->isValid()) {
|
|
|
|
|
|
pushCogFix((int32_t)loc->getLatitude(), (int32_t)loc->getLongitude());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Insert a GPS fix into the course-over-ground ring, rejecting gross outliers
|
|
|
|
|
|
// (a jump implying an impossible speed) so one bad fix can't swing the heading.
|
|
|
|
|
|
void UITask::pushCogFix(int32_t lat, int32_t lon) {
|
2026-06-04 00:12:15 +02:00
|
|
|
|
static const uint32_t COG_MAX_GAP_MS = 15000; // GPS gap longer than this → window is stale
|
2026-06-03 08:20:59 +02:00
|
|
|
|
uint32_t now = millis();
|
|
|
|
|
|
if (_cog_count > 0) {
|
|
|
|
|
|
const CogFix& prev = _cog[(_cog_head + _cog_count - 1) % COG_RING];
|
|
|
|
|
|
uint32_t dt = now - prev.ms;
|
2026-06-04 00:12:15 +02:00
|
|
|
|
if (dt > COG_MAX_GAP_MS) {
|
|
|
|
|
|
// GPS was lost for a while: the old fixes are far in the past, so a
|
|
|
|
|
|
// window spanning them would imply a bogus "teleport" heading. Restart
|
|
|
|
|
|
// the ring from this fix (the last-good _cog_deg is kept for display).
|
|
|
|
|
|
_cog_head = 0; _cog_count = 0;
|
|
|
|
|
|
} else if (dt > 0) {
|
2026-06-03 08:20:59 +02:00
|
|
|
|
float dist_m = geo::haversineKm(prev.lat, prev.lon, lat, lon) * 1000.0f;
|
|
|
|
|
|
float speed = dist_m / (dt / 1000.0f); // m/s
|
|
|
|
|
|
if (speed > 50.0f) return; // > 180 km/h between fixes → reject
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
int pos;
|
|
|
|
|
|
if (_cog_count < COG_RING) { pos = (_cog_head + _cog_count) % COG_RING; _cog_count++; }
|
|
|
|
|
|
else { pos = _cog_head; _cog_head = (_cog_head + 1) % COG_RING; }
|
|
|
|
|
|
_cog[pos].lat = lat; _cog[pos].lon = lon; _cog[pos].ms = now;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool UITask::currentCourse(int& deg_out) const {
|
|
|
|
|
|
static const float COG_MIN_MOVE_M = 6.0f; // window must span ≥ this to be a real heading
|
|
|
|
|
|
if (_cog_count < 2) {
|
|
|
|
|
|
if (_cog_deg >= 0) { deg_out = _cog_deg; return true; } // hold last good
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
const CogFix& oldest = _cog[_cog_head];
|
|
|
|
|
|
const CogFix& newest = _cog[(_cog_head + _cog_count - 1) % COG_RING];
|
|
|
|
|
|
float span_m = geo::haversineKm(oldest.lat, oldest.lon, newest.lat, newest.lon) * 1000.0f;
|
|
|
|
|
|
if (span_m < COG_MIN_MOVE_M) {
|
|
|
|
|
|
if (_cog_deg >= 0) { deg_out = _cog_deg; return true; } // standing still → hold last
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Cache as last-good (mutable-free: recompute is cheap, but keep _cog_deg fresh).
|
|
|
|
|
|
const_cast<UITask*>(this)->_cog_deg =
|
|
|
|
|
|
geo::bearingDeg(oldest.lat, oldest.lon, newest.lat, newest.lon);
|
|
|
|
|
|
deg_out = _cog_deg;
|
|
|
|
|
|
return true;
|
2025-03-04 23:09:43 +11:00
|
|
|
|
}
|
2025-05-27 19:10:56 -07:00
|
|
|
|
|
2026-06-04 00:12:15 +02:00
|
|
|
|
bool UITask::currentLocation(int32_t& lat, int32_t& lon) const {
|
|
|
|
|
|
LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr;
|
|
|
|
|
|
if (loc && loc->isValid()) {
|
|
|
|
|
|
lat = (int32_t)loc->getLatitude();
|
|
|
|
|
|
lon = (int32_t)loc->getLongitude();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 08:23:11 +02:00
|
|
|
|
void UITask::saveWaypoints() {
|
|
|
|
|
|
DataStore* ds = the_mesh.getDataStore();
|
|
|
|
|
|
if (!ds) return;
|
|
|
|
|
|
File f = ds->openWrite("/waypoints");
|
|
|
|
|
|
if (!f) return;
|
|
|
|
|
|
_waypoints.writeTo(f);
|
|
|
|
|
|
f.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-08 20:01:31 +10:00
|
|
|
|
char UITask::checkDisplayOn(char c) {
|
2025-05-27 19:10:56 -07:00
|
|
|
|
if (_display != NULL) {
|
2025-08-08 20:01:31 +10:00
|
|
|
|
if (!_display->isOn()) {
|
2026-05-10 18:01:59 +02:00
|
|
|
|
_display->turnOn();
|
|
|
|
|
|
#ifdef PIN_LED
|
|
|
|
|
|
digitalWrite(PIN_LED, LOW); // ensure LED is off when waking display (userLedHandler takes over)
|
|
|
|
|
|
#endif
|
2026-05-17 09:55:51 +02:00
|
|
|
|
if (_locked) {
|
|
|
|
|
|
_lock_wake_until = millis() + 5000;
|
|
|
|
|
|
_next_refresh = 0;
|
|
|
|
|
|
return 0; // eat the waking key press
|
|
|
|
|
|
}
|
2026-05-23 20:03:45 +02:00
|
|
|
|
_lock_seq_count = 0;
|
|
|
|
|
|
_lock_seq_used = false;
|
2025-08-08 20:01:31 +10:00
|
|
|
|
c = 0;
|
2025-05-27 19:10:56 -07:00
|
|
|
|
}
|
2026-05-17 09:55:51 +02:00
|
|
|
|
if (!_locked) {
|
|
|
|
|
|
uint32_t aoff = autoOffMillis();
|
|
|
|
|
|
if (aoff > 0) _auto_off = millis() + aoff; // extend auto-off timer
|
|
|
|
|
|
}
|
2025-08-08 20:01:31 +10:00
|
|
|
|
_next_refresh = 0; // trigger refresh
|
2025-05-27 19:10:56 -07:00
|
|
|
|
}
|
2025-08-08 20:01:31 +10:00
|
|
|
|
return c;
|
2025-05-27 19:10:56 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-08 20:01:31 +10:00
|
|
|
|
char UITask::handleLongPress(char c) {
|
|
|
|
|
|
if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue
|
|
|
|
|
|
the_mesh.enterCLIRescue();
|
2026-05-11 18:56:26 +02:00
|
|
|
|
return 0;
|
2025-05-27 19:10:56 -07:00
|
|
|
|
}
|
2026-05-11 18:56:26 +02:00
|
|
|
|
if (c == KEY_ENTER) return KEY_CONTEXT_MENU;
|
2025-08-08 20:01:31 +10:00
|
|
|
|
return c;
|
2025-05-27 19:10:56 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-03 08:25:59 +10:00
|
|
|
|
char UITask::handleDoubleClick(char c) {
|
2026-06-02 22:12:18 +02:00
|
|
|
|
MESH_DEBUG_PRINTLN("UITask: double-click triggered");
|
2025-09-03 08:31:38 +10:00
|
|
|
|
checkDisplayOn(c);
|
2025-09-03 08:25:59 +10:00
|
|
|
|
return c;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
char UITask::handleTripleClick(char c) {
|
2025-09-03 08:31:38 +10:00
|
|
|
|
checkDisplayOn(c);
|
2025-09-03 08:25:59 +10:00
|
|
|
|
toggleBuzzer();
|
2026-05-23 20:03:45 +02:00
|
|
|
|
return 0;
|
2025-09-03 08:25:59 +10:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-23 10:39:43 +02:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-28 21:26:56 -07:00
|
|
|
|
}
|
2025-09-23 10:39:43 +02:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-03 08:25:59 +10:00
|
|
|
|
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");
|
2025-11-29 16:37:10 +08:00
|
|
|
|
_node_prefs->gps_enabled = 0;
|
2025-09-17 08:53:50 +08:00
|
|
|
|
notify(UIEventType::ack);
|
2025-09-03 08:25:59 +10:00
|
|
|
|
} else {
|
|
|
|
|
|
_sensors->setSettingValue("gps", "1");
|
2025-11-29 16:37:10 +08:00
|
|
|
|
_node_prefs->gps_enabled = 1;
|
2025-09-17 08:53:50 +08:00
|
|
|
|
notify(UIEventType::ack);
|
2025-09-03 08:25:59 +10:00
|
|
|
|
}
|
2025-11-29 16:37:10 +08:00
|
|
|
|
the_mesh.savePrefs();
|
2025-12-11 09:26:09 +01:00
|
|
|
|
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
|
2025-09-03 08:25:59 +10:00
|
|
|
|
_next_refresh = 0;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 15:18:35 +02:00
|
|
|
|
void UITask::applyTxPower() {
|
|
|
|
|
|
if (_node_prefs == NULL) return;
|
2026-06-06 17:21:41 +02:00
|
|
|
|
radio_driver.setTxPower(_node_prefs->tx_power_dbm);
|
2026-05-10 15:18:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void UITask::applyBrightness() {
|
|
|
|
|
|
if (_display != NULL && _node_prefs != NULL) {
|
|
|
|
|
|
_display->setBrightness(_node_prefs->display_brightness);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 09:29:38 +02:00
|
|
|
|
void UITask::applyFont() {
|
|
|
|
|
|
if (_display != NULL && _node_prefs != NULL) {
|
|
|
|
|
|
_display->setLemonFont(_node_prefs->use_lemon_font != 0);
|
2026-05-20 09:36:27 +02:00
|
|
|
|
_next_refresh = 0;
|
2026-05-20 09:29:38 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:53:40 +02:00
|
|
|
|
void UITask::applyRotation() {
|
|
|
|
|
|
if (_display != NULL && _node_prefs != NULL) {
|
|
|
|
|
|
_display->setDisplayRotation(_node_prefs->display_rotation);
|
|
|
|
|
|
_next_refresh = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 10:22:03 +02:00
|
|
|
|
void UITask::applyFullRefreshInterval() {
|
|
|
|
|
|
if (_display != NULL && _node_prefs != NULL) {
|
|
|
|
|
|
static const uint8_t OPTS[] = { 0, 5, 10, 20, 30 };
|
|
|
|
|
|
static const int OPTS_COUNT = 5;
|
|
|
|
|
|
uint8_t idx = _node_prefs->eink_full_refresh_every;
|
|
|
|
|
|
if (idx >= OPTS_COUNT) idx = 0;
|
|
|
|
|
|
_display->setFullRefreshInterval(OPTS[idx]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 15:18:35 +02:00
|
|
|
|
void UITask::setBrightnessLevel(uint8_t level) {
|
|
|
|
|
|
if (_node_prefs == NULL) return;
|
|
|
|
|
|
if (level > 4) level = 4;
|
|
|
|
|
|
_node_prefs->display_brightness = level;
|
|
|
|
|
|
applyBrightness();
|
|
|
|
|
|
_next_refresh = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 09:17:37 +02:00
|
|
|
|
void UITask::setBuzzerVolumeLevel(uint8_t level) {
|
|
|
|
|
|
#ifdef PIN_BUZZER
|
|
|
|
|
|
if (_node_prefs == NULL) return;
|
|
|
|
|
|
if (level > 4) level = 4;
|
|
|
|
|
|
_node_prefs->buzzer_volume = level;
|
|
|
|
|
|
buzzer.setVolume(level);
|
2026-05-20 09:06:00 +02:00
|
|
|
|
if (level > 0) buzzer.playForced("Vol:d=16,o=6,b=120:c");
|
2026-05-12 09:17:37 +02:00
|
|
|
|
_next_refresh = 0;
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-03 08:25:59 +10:00
|
|
|
|
void UITask::toggleBuzzer() {
|
2025-05-27 19:10:56 -07:00
|
|
|
|
#ifdef PIN_BUZZER
|
2026-05-12 20:12:54 +02:00
|
|
|
|
if (_node_prefs) _node_prefs->buzzer_auto = 0; // exit auto mode
|
2025-05-30 22:55:53 -07:00
|
|
|
|
if (buzzer.isQuiet()) {
|
|
|
|
|
|
buzzer.quiet(false);
|
2025-09-17 08:53:50 +08:00
|
|
|
|
notify(UIEventType::ack);
|
2025-05-30 22:55:53 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
buzzer.quiet(true);
|
|
|
|
|
|
}
|
2026-05-12 20:12:54 +02:00
|
|
|
|
if (_node_prefs) _node_prefs->buzzer_quiet = buzzer.isQuiet();
|
2025-11-20 18:55:39 -08:00
|
|
|
|
the_mesh.savePrefs();
|
2025-12-11 09:26:09 +01:00
|
|
|
|
showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800);
|
2026-05-12 20:12:54 +02:00
|
|
|
|
_next_refresh = 0;
|
2025-05-27 19:10:56 -07:00
|
|
|
|
#endif
|
|
|
|
|
|
}
|
2026-05-12 20:12:54 +02:00
|
|
|
|
|
|
|
|
|
|
int UITask::getBuzzerMode() {
|
|
|
|
|
|
#ifdef PIN_BUZZER
|
|
|
|
|
|
if (_node_prefs && _node_prefs->buzzer_auto) return 2;
|
|
|
|
|
|
return buzzer.isQuiet() ? 1 : 0;
|
|
|
|
|
|
#else
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void UITask::cycleBuzzerMode() {
|
|
|
|
|
|
#ifdef PIN_BUZZER
|
|
|
|
|
|
if (!_node_prefs) return;
|
|
|
|
|
|
int mode = getBuzzerMode();
|
|
|
|
|
|
mode = (mode + 1) % 3; // ON(0) → OFF(1) → Auto(2) → ON
|
|
|
|
|
|
_node_prefs->buzzer_auto = (mode == 2) ? 1 : 0;
|
|
|
|
|
|
if (mode == 0) { buzzer.quiet(false); _node_prefs->buzzer_quiet = 0; notify(UIEventType::ack); }
|
|
|
|
|
|
if (mode == 1) { buzzer.quiet(true); _node_prefs->buzzer_quiet = 1; }
|
2026-06-05 12:42:14 +02:00
|
|
|
|
if (mode == 2) { buzzer.quiet(isClientConnected()); }
|
2026-05-12 20:12:54 +02:00
|
|
|
|
static const char* labels[] = { "Buzzer: ON", "Buzzer: OFF", "Buzzer: Auto" };
|
|
|
|
|
|
showAlert(labels[mode], 800);
|
|
|
|
|
|
_next_refresh = 0;
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|