mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-23 06:45:24 -04:00
* fix(pki): re-derive NodeNum when setting a region mints the identity key
A node's mesh address is derived from its identity key:
my_node_num == crc32Buffer(config.security.public_key.bytes, 32)
NodeDB::createNewIdentity() is what establishes that, and NodeDB::
generateCryptoKeyPair() is the only thing that called it.
CryptoEngine::ensurePkiKeys() generates or re-derives the keypair and writes
security.public_key, security.private_key and user.public_key - but never
re-derives my_node_num. Boot-time keygen is suppressed while the LoRa region is
UNSET (generateCryptoKeyPair()'s regionBlocksKeygen guard), so on a fresh device
my_node_num is still the MAC-derived value from pickNewNodeNum(). The user then
sets the region - the stock onboarding flow - ensurePkiKeys() mints a key, and
the invariant is broken.
The node then signs its broadcasts (Router.cpp signs when !pki_encrypted &&
(owner.is_licensed || isBroadcast(p->to))). Every receiver runs
verifyFirstContactNodeInfo, fails crc32Buffer(user.public_key) != p->from, and
drops the NodeInfo. The node's identity beacons are invisible to the mesh.
Nothing reboots to repair it: AdminModule sets requiresReboot = false for LoRa
changes ("All LoRa radio changes apply live via configChanged observer") and
MenuHandler ends at service->reloadConfig(changes).
Four call sites reached ensurePkiKeys():
1. AdminModule set_config LORA, region first set (phone app - the common path)
2. MenuHandler applyLoraRegion (on-device region picker)
3. InkHUD MenuApplet applyLoRaRegion (schedules a reboot, so it
self-healed at next boot)
4. portduino wasm wasm_set_region
The reference implementation was already in the tree: the *licensed* branch of
call site 1, thirteen lines below the broken unlicensed one, calls
nodeDB->generateCryptoKeyPair() (which reaches createNewIdentity()) and widens
the persisted mask with SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE.
Rather than repeat that at four call sites, the key-mint is routed through one
chokepoint that owns both halves of the identity: NodeDB::ensurePkiIdentity()
calls crypto->ensurePkiKeys() and then createNewIdentity(). It lives in NodeDB
because createNewIdentity() operates on the devicestate/node-DB globals, which
CryptoEngine deliberately does not touch - ensurePkiKeys() takes the security
config and user by reference precisely so it stays free of that dependency, and
it is unit-tested against a standalone CryptoEngine.
ensurePkiIdentity() returns true only when my_node_num actually moved
(createNewIdentity() early-returns when the key is unchanged, so a repeat region
change does not disturb the self entry or force a needless flash write). Callers
use that to widen their save mask; my_node_num lives in devicestate and the self
row moves in the node DB, so both segments must be persisted or the fix would
revert at the next boot. SEGMENT_CONFIG, which carries the key itself, is
already unconditional on all four paths.
The InkHUD reboot is left as-is. It is now redundant for this invariant, but it
covers the rest of that menu's behaviour and a redundant reboot is not a bug.
Adds test_handleSetConfig_persistsUnlicensedFirstRegionIdentity, the unlicensed
twin of the existing licensed test, asserting both the segment mask and
my_node_num == crc32(public_key).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style(NodeDB): trim identity-recovery comments and guard the WASM nodeDB deref
Two review asks, no behaviour change on any built target.
Copilot flagged the unguarded nodeDB deref in the WASM region setter; it is the
only ensurePkiIdentity() call site that did not check the pointer first.
The rest is comment length. AGENTS.md:83 caps code comments at two lines, and the
identity-recovery comments across the four call sites plus the NodeDB.h doc block
ran to four and six lines. The rationale they carried is in the commit messages
and the PR body, which is where AGENTS.md says it belongs.
The PR's own fix in AdminModule.cpp is deliberately untouched.
* fix(NodeDB): keep the identity move authoritative when the self record cannot be created
createNewIdentity() removes the old node entry and assigns myNodeInfo.my_node_num
before it tries to create the row for the new number. If getOrCreateMeshNode()
came back null it returned false, so the first-region callers left
SEGMENT_DEVICESTATE and SEGMENT_NODEDATABASE out of the save mask.
The number had already moved in RAM at that point, and the freshly minted key
goes to flash under SEGMENT_CONFIG regardless. The next boot therefore reloads
the old number alongside the new key, which is exactly the
crc32(public_key) != my_node_num break this path exists to prevent, reached
through the error branch instead of the happy one.
Rolling the number back is not an option either, since the key has already been
replaced by the time this runs. So the move is now reported as the fact it is and
the missing self record is logged separately; getOrCreateMeshNode() will recreate
that row on the next contact. Reachable when the self record is absent and the
table is full of protected nodes.
Reported by CodeRabbit on #11426.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2648 lines
98 KiB
C++
2648 lines
98 KiB
C++
#ifdef MESHTASTIC_INCLUDE_INKHUD
|
|
|
|
#include "./MenuApplet.h"
|
|
|
|
#include "DisplayFormatters.h"
|
|
#include "GPS.h"
|
|
#include "MeshRadio.h"
|
|
#include "MeshService.h"
|
|
#include "MessageStore.h"
|
|
#include "Power.h"
|
|
#include "Router.h"
|
|
#include "airtime.h"
|
|
#include "gps/RTC.h"
|
|
#include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h"
|
|
#include "graphics/niche/Utils/FlashData.h"
|
|
#include "main.h"
|
|
#include "mesh/generated/meshtastic/deviceonly.pb.h"
|
|
#include <RadioLibInterface.h>
|
|
#include <target_specific.h>
|
|
#if defined(ARCH_ESP32) && HAS_WIFI
|
|
#include "mesh/wifi/WiFiAPClient.h"
|
|
#include <WiFi.h>
|
|
#include <esp_wifi.h>
|
|
#endif
|
|
|
|
using namespace NicheGraphics;
|
|
|
|
static constexpr uint8_t MENU_TIMEOUT_SEC = 60; // How many seconds before menu auto-closes
|
|
|
|
// Options for the "Recents" menu
|
|
// These are offered to users as possible values for settings.recentlyActiveSeconds
|
|
static constexpr uint8_t RECENTS_OPTIONS_MINUTES[] = {2, 5, 10, 30, 60, 120};
|
|
|
|
struct DisplayTimeoutOption {
|
|
uint32_t seconds;
|
|
const char *label;
|
|
};
|
|
|
|
struct UInt32Option {
|
|
uint32_t value;
|
|
const char *label;
|
|
};
|
|
|
|
static constexpr DisplayTimeoutOption DISPLAY_TIMEOUT_OPTIONS[] = {
|
|
{0, "Forever"}, {30, "30 secs"}, {60, "1 min"}, {5 * 60, "5 min"},
|
|
{15 * 60, "15 min"}, {30 * 60, "30 min"}, {60 * 60, "1 hr"},
|
|
};
|
|
|
|
static constexpr UInt32Option POSITION_BROADCAST_OPTIONS[] = {
|
|
{60, "1 min"}, {90, "90 sec"}, {5 * 60, "5 min"}, {15 * 60, "15 min"},
|
|
{60 * 60, "1 hr"}, {2 * 60 * 60, "2 hr"}, {3 * 60 * 60, "3 hr"}, {4 * 60 * 60, "4 hr"},
|
|
{5 * 60 * 60, "5 hr"}, {6 * 60 * 60, "6 hr"}, {12 * 60 * 60, "12 hr"}, {18 * 60 * 60, "18 hr"},
|
|
{24 * 60 * 60, "24 hr"}, {36 * 60 * 60, "36 hr"}, {48 * 60 * 60, "48 hr"}, {72 * 60 * 60, "72 hr"},
|
|
};
|
|
|
|
static constexpr UInt32Option GPS_UPDATE_INTERVAL_OPTIONS[] = {
|
|
{8, "8 sec"}, {20, "20 sec"}, {40, "40 sec"}, {60, "1 min"}, {80, "80 sec"},
|
|
{2 * 60, "2 min"}, {5 * 60, "5 min"}, {10 * 60, "10 min"}, {15 * 60, "15 min"}, {30 * 60, "30 min"},
|
|
{60 * 60, "1 hr"}, {6 * 60 * 60, "6 hr"}, {12 * 60 * 60, "12 hr"}, {24 * 60 * 60, "24 hr"}, {2147483647UL, "At Boot"},
|
|
};
|
|
|
|
static constexpr UInt32Option SMART_INTERVAL_OPTIONS[] = {
|
|
{5 * 60, "5 min"}, {10 * 60, "10 min"}, {15 * 60, "15 min"}, {30 * 60, "30 min"}, {60 * 60, "1 hr"},
|
|
{2 * 60 * 60, "2 hr"}, {6 * 60 * 60, "6 hr"}, {12 * 60 * 60, "12 hr"}, {24 * 60 * 60, "24 hr"},
|
|
};
|
|
|
|
static constexpr uint32_t SMART_DISTANCE_OPTIONS[] = {20, 50, 100, 250, 500, 1000, 2000, 5000};
|
|
|
|
struct PositionPrecisionOption {
|
|
uint8_t value; // proto value
|
|
const char *metric;
|
|
const char *imperial;
|
|
};
|
|
|
|
static constexpr PositionPrecisionOption POSITION_PRECISION_OPTIONS[] = {
|
|
{32, "Precise", "Precise"}, {19, "50 m", "150 ft"}, {18, "90 m", "300 ft"}, {17, "200 m", "600 ft"},
|
|
{16, "350 m", "0.2 mi"}, {15, "700 m", "0.5 mi"}, {14, "1.5 km", "0.9 mi"}, {13, "2.9 km", "1.8 mi"},
|
|
{12, "5.8 km", "3.6 mi"}, {11, "12 km", "7.3 mi"}, {10, "23 km", "15 mi"},
|
|
};
|
|
|
|
static const char *getDisplayTimeoutLabel(uint32_t timeoutSeconds)
|
|
{
|
|
constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]);
|
|
for (uint8_t i = 0; i < optionCount; i++) {
|
|
if (DISPLAY_TIMEOUT_OPTIONS[i].seconds == timeoutSeconds) {
|
|
return DISPLAY_TIMEOUT_OPTIONS[i].label;
|
|
}
|
|
}
|
|
|
|
return "Custom";
|
|
}
|
|
|
|
static std::string getUInt32OptionLabel(const UInt32Option *options, uint8_t optionCount, uint32_t value,
|
|
const char *zeroLabel = nullptr)
|
|
{
|
|
for (uint8_t i = 0; i < optionCount; i++) {
|
|
if (options[i].value == value) {
|
|
return options[i].label;
|
|
}
|
|
}
|
|
|
|
if (value == 0) {
|
|
return zeroLabel ? zeroLabel : "0 sec";
|
|
}
|
|
if (value == 2147483647UL) {
|
|
return "At Boot";
|
|
}
|
|
if (value % (60 * 60) == 0) {
|
|
return std::to_string(value / (60 * 60)) + " hr";
|
|
}
|
|
if (value % 60 == 0) {
|
|
return std::to_string(value / 60) + " min";
|
|
}
|
|
return std::to_string(value) + " sec";
|
|
}
|
|
|
|
static bool supportsFreeTextKeyboard(const InkHUD::InkHUD *inkhud, const InkHUD::Persistence::Settings *settings)
|
|
{
|
|
return !inkhud->twoWayRocker && (settings->joystick.enabled || inkhud->hasTouchEnabledProvider());
|
|
}
|
|
|
|
static bool useTouchFriendlyMenuLayout(const InkHUD::InkHUD *inkhud)
|
|
{
|
|
return inkhud != nullptr && inkhud->hasTouchEnabledProvider();
|
|
}
|
|
|
|
static uint16_t getMenuItemHeightPx(const InkHUD::InkHUD *inkhud)
|
|
{
|
|
const bool touchFriendly = useTouchFriendlyMenuLayout(inkhud);
|
|
const uint16_t lineH = touchFriendly ? InkHUD::Applet::fontMedium.lineHeight() : InkHUD::Applet::fontSmall.lineHeight();
|
|
const float rowScale = touchFriendly ? 1.9f : 1.6f;
|
|
uint16_t itemH = (uint16_t)(lineH * rowScale);
|
|
if (itemH == 0) {
|
|
itemH = 1;
|
|
}
|
|
return itemH;
|
|
}
|
|
|
|
#if defined(T5_S3_EPAPER_PRO)
|
|
namespace
|
|
{
|
|
static constexpr uint32_t T5_BACKLIGHT_PREFS_VERSION = 1;
|
|
|
|
struct T5BacklightPrefs {
|
|
uint32_t version = T5_BACKLIGHT_PREFS_VERSION;
|
|
bool keepOn = true;
|
|
};
|
|
|
|
T5BacklightPrefs t5BacklightPrefs;
|
|
bool t5BacklightPrefsLoaded = false;
|
|
|
|
bool loadT5BacklightKeepOn()
|
|
{
|
|
if (!t5BacklightPrefsLoaded) {
|
|
T5BacklightPrefs loaded;
|
|
const bool ok = FlashData<T5BacklightPrefs>::load(&loaded, "t5_backlight");
|
|
if (ok && loaded.version == T5_BACKLIGHT_PREFS_VERSION) {
|
|
t5BacklightPrefs = loaded;
|
|
}
|
|
t5BacklightPrefsLoaded = true;
|
|
}
|
|
|
|
return t5BacklightPrefs.keepOn;
|
|
}
|
|
|
|
void saveT5BacklightKeepOn(bool keepOn)
|
|
{
|
|
loadT5BacklightKeepOn();
|
|
t5BacklightPrefs.version = T5_BACKLIGHT_PREFS_VERSION;
|
|
t5BacklightPrefs.keepOn = keepOn;
|
|
FlashData<T5BacklightPrefs>::save(&t5BacklightPrefs, "t5_backlight");
|
|
}
|
|
} // namespace
|
|
#endif
|
|
|
|
InkHUD::MenuApplet::MenuApplet() : concurrency::OSThread("MenuApplet")
|
|
{
|
|
// No timer tasks at boot
|
|
OSThread::disable();
|
|
|
|
// Note: don't get instance if we're not actually using the backlight,
|
|
// or else you will unintentionally instantiate it
|
|
if (settings->optionalMenuItems.backlight) {
|
|
#if defined(T5_S3_EPAPER_PRO)
|
|
t5BacklightSetUserEnabled(loadT5BacklightKeepOn());
|
|
#else
|
|
backlight = Drivers::LatchingBacklight::getInstance();
|
|
#endif
|
|
}
|
|
|
|
// Initialize the Canned Message store
|
|
// This is a shared nicheGraphics component
|
|
// - handles loading & parsing the canned messages
|
|
// - handles setting / getting of canned messages via apps (Client API Admin Messages)
|
|
cm.store = CannedMessageStore::getInstance();
|
|
}
|
|
|
|
void InkHUD::MenuApplet::onForeground()
|
|
{
|
|
// We do need this before we render, but we can optimize by just calculating it once now
|
|
systemInfoPanelHeight = getSystemInfoPanelHeight();
|
|
|
|
// Force Region page ONLY when explicitly requested (one-shot)
|
|
if (inkhud->forceRegionMenu) {
|
|
|
|
inkhud->forceRegionMenu = false; // consume one-shot flag
|
|
showPage(MenuPage::REGION);
|
|
|
|
} else {
|
|
showPage(MenuPage::ROOT);
|
|
}
|
|
|
|
// If device has a backlight which isn't controlled by aux button:
|
|
// backlight on always when menu opens.
|
|
// Courtesy to T-Echo users who removed the capacitive touch button
|
|
if (settings->optionalMenuItems.backlight) {
|
|
#if !defined(T5_S3_EPAPER_PRO)
|
|
assert(backlight);
|
|
if (!backlight->isOn())
|
|
backlight->peek();
|
|
#endif
|
|
}
|
|
|
|
// Prevent user applets requesting update while menu is open
|
|
// Handle button input with this applet
|
|
SystemApplet::lockRequests = true;
|
|
SystemApplet::handleInput = true;
|
|
|
|
// Begin the auto-close timeout
|
|
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
|
|
OSThread::enabled = true;
|
|
|
|
freeTextMode = false;
|
|
|
|
// Upgrade the refresh to FAST, for guaranteed responsiveness
|
|
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
|
|
}
|
|
|
|
void InkHUD::MenuApplet::onBackground()
|
|
{
|
|
// Discard any data we generated while selecting a canned message
|
|
// Frees heap mem
|
|
freeCannedMessageResources();
|
|
|
|
// If device has a backlight which isn't controlled by aux button:
|
|
// Item in options submenu allows keeping backlight on after menu is closed
|
|
// If this item is deselected we will turn backlight off again, now that menu is closing
|
|
if (settings->optionalMenuItems.backlight) {
|
|
#if !defined(T5_S3_EPAPER_PRO)
|
|
assert(backlight);
|
|
if (!backlight->isLatched())
|
|
backlight->off();
|
|
#endif
|
|
}
|
|
|
|
// Stop the auto-timeout
|
|
OSThread::disable();
|
|
|
|
// Resume normal rendering and button behavior of user applets
|
|
SystemApplet::lockRequests = false;
|
|
SystemApplet::handleInput = false;
|
|
|
|
handleFreeText = false;
|
|
|
|
// Restore the user applet whose tile we borrowed
|
|
if (borrowedTileOwner)
|
|
borrowedTileOwner->bringToForeground();
|
|
Tile *t = getTile();
|
|
t->assignApplet(borrowedTileOwner); // Break our link with the tile, (and relink it with real owner, if it had one)
|
|
borrowedTileOwner = nullptr;
|
|
|
|
// Need to force an update, as a polite request wouldn't be honored, seeing how we are now in the background
|
|
// We're only updating here to upgrade from UNSPECIFIED to FAST, to ensure responsiveness when exiting menu
|
|
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
|
|
}
|
|
|
|
// Open the menu
|
|
// Parameter specifies which user-tile the menu will use
|
|
// The user applet originally on this tile will be restored when the menu closes
|
|
void InkHUD::MenuApplet::show(Tile *t)
|
|
{
|
|
// Remember who *really* owns this tile
|
|
borrowedTileOwner = t->getAssignedApplet();
|
|
|
|
// Hide the owner, if it is a valid applet
|
|
if (borrowedTileOwner)
|
|
borrowedTileOwner->sendToBackground();
|
|
|
|
// Break the owner's link with tile
|
|
// Relink it to menu applet
|
|
t->assignApplet(this);
|
|
|
|
// Show menu
|
|
bringToForeground();
|
|
}
|
|
|
|
// Auto-exit the menu applet after a period of inactivity
|
|
// The values shown on the root menu are only a snapshot: they are not re-rendered while the menu remains open.
|
|
// By exiting the menu, we prevent users mistakenly believing that the data will update.
|
|
int32_t InkHUD::MenuApplet::runOnce()
|
|
{
|
|
// runOnce's interval is pushed back when a button is pressed
|
|
// If we do actually run, it means no button input occurred within MENU_TIMEOUT_SEC,
|
|
// so we close the menu.
|
|
showPage(EXIT);
|
|
|
|
// Timer should disable after firing
|
|
// This is redundant, as onBackground() will also disable
|
|
return OSThread::disable();
|
|
}
|
|
|
|
// Storage for the dynamically-built region preset list - populated in showPage(NODE_CONFIG_PRESET)
|
|
static constexpr uint8_t MAX_REGION_PRESETS = 16;
|
|
static meshtastic_Config_LoRaConfig_ModemPreset regionPresets[MAX_REGION_PRESETS];
|
|
static uint8_t regionPresetCount = 0;
|
|
|
|
static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
|
|
{
|
|
if (config.lora.region == region)
|
|
return;
|
|
|
|
config.lora.region = region;
|
|
|
|
auto changes = SEGMENT_CONFIG;
|
|
|
|
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
|
// Minting the key moves our node num with it, and the reboot below only re-derives after the save.
|
|
if (nodeDB->ensurePkiIdentity()) {
|
|
changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE;
|
|
}
|
|
#endif
|
|
|
|
config.lora.tx_enabled = true;
|
|
|
|
initRegion();
|
|
|
|
if (myRegion && getEffectiveDutyCycle() < 100) {
|
|
config.lora.ignore_mqtt = true;
|
|
}
|
|
|
|
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
|
|
snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name);
|
|
changes |= SEGMENT_MODULECONFIG;
|
|
}
|
|
// Notify UI that changes are being applied
|
|
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
|
|
service->reloadConfig(changes);
|
|
|
|
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
|
}
|
|
|
|
static void applyDeviceRole(meshtastic_Config_DeviceConfig_Role role)
|
|
{
|
|
if (config.device.role == role)
|
|
return;
|
|
|
|
config.device.role = role;
|
|
|
|
nodeDB->saveToDisk(SEGMENT_CONFIG);
|
|
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
|
|
// Notify UI that changes are being applied
|
|
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
|
|
|
|
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
|
}
|
|
|
|
static void applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset preset)
|
|
{
|
|
if (config.lora.modem_preset == preset)
|
|
return;
|
|
|
|
config.lora.use_preset = true;
|
|
config.lora.modem_preset = preset;
|
|
|
|
nodeDB->saveToDisk(SEGMENT_CONFIG);
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
|
|
// Notify UI that changes are being applied
|
|
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
|
|
|
|
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
|
}
|
|
|
|
static void applyConfigReload(uint32_t changes = SEGMENT_CONFIG, bool reboot = false)
|
|
{
|
|
nodeDB->saveToDisk(changes);
|
|
service->reloadConfig(changes);
|
|
|
|
if (reboot) {
|
|
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
|
|
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
|
}
|
|
}
|
|
|
|
static const char *getTimezoneLabelFromValue(const char *tzdef)
|
|
{
|
|
if (!tzdef || !*tzdef)
|
|
return "Unset";
|
|
|
|
// Must match TIMEZONE menu entries
|
|
if (strcmp(tzdef, "HST10") == 0)
|
|
return "US/Hawaii";
|
|
if (strcmp(tzdef, "AKST9AKDT,M3.2.0,M11.1.0") == 0)
|
|
return "US/Alaska";
|
|
if (strcmp(tzdef, "PST8PDT,M3.2.0,M11.1.0") == 0)
|
|
return "US/Pacific";
|
|
if (strcmp(tzdef, "MST7") == 0)
|
|
return "US/Arizona";
|
|
if (strcmp(tzdef, "MST7MDT,M3.2.0,M11.1.0") == 0)
|
|
return "US/Mountain";
|
|
if (strcmp(tzdef, "CST6CDT,M3.2.0,M11.1.0") == 0)
|
|
return "US/Central";
|
|
if (strcmp(tzdef, "EST5EDT,M3.2.0,M11.1.0") == 0)
|
|
return "US/Eastern";
|
|
if (strcmp(tzdef, "BRT3") == 0)
|
|
return "BR/Brasilia";
|
|
if (strcmp(tzdef, "UTC0") == 0)
|
|
return "UTC";
|
|
if (strcmp(tzdef, "GMT0BST,M3.5.0/1,M10.5.0") == 0)
|
|
return "EU/Western";
|
|
if (strcmp(tzdef, "CET-1CEST,M3.5.0,M10.5.0/3") == 0)
|
|
return "EU/Central";
|
|
if (strcmp(tzdef, "EET-2EEST,M3.5.0/3,M10.5.0/4") == 0)
|
|
return "EU/Eastern";
|
|
if (strcmp(tzdef, "IST-5:30") == 0)
|
|
return "Asia/Kolkata";
|
|
if (strcmp(tzdef, "HKT-8") == 0)
|
|
return "Asia/Hong Kong";
|
|
if (strcmp(tzdef, "AWST-8") == 0)
|
|
return "AU/AWST";
|
|
if (strcmp(tzdef, "ACST-9:30ACDT,M10.1.0,M4.1.0/3") == 0)
|
|
return "AU/ACST";
|
|
if (strcmp(tzdef, "AEST-10AEDT,M10.1.0,M4.1.0/3") == 0)
|
|
return "AU/AEST";
|
|
if (strcmp(tzdef, "NZST-12NZDT,M9.5.0,M4.1.0/3") == 0)
|
|
return "Pacific/NZ";
|
|
|
|
return tzdef; // fallback for unknown/custom values
|
|
}
|
|
|
|
static void applyTimezone(const char *tz)
|
|
{
|
|
if (!tz || strcmp(config.device.tzdef, tz) == 0)
|
|
return;
|
|
|
|
strncpy(config.device.tzdef, tz, sizeof(config.device.tzdef));
|
|
config.device.tzdef[sizeof(config.device.tzdef) - 1] = '\0';
|
|
|
|
setenv("TZ", config.device.tzdef, 1);
|
|
|
|
nodeDB->saveToDisk(SEGMENT_CONFIG);
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
}
|
|
|
|
// Perform action for a menu item, then change page
|
|
// Behaviors for MenuActions are defined here
|
|
void InkHUD::MenuApplet::execute(MenuItem item)
|
|
{
|
|
// Perform an action
|
|
// ------------------
|
|
switch (item.action) {
|
|
|
|
// Open a submenu without performing any action
|
|
// Also handles exit
|
|
case NO_ACTION:
|
|
if (currentPage == MenuPage::NODE_CONFIG_CHANNELS && item.nextPage == MenuPage::NODE_CONFIG_CHANNEL_DETAIL) {
|
|
|
|
// cursor - 1 because index 0 is "Back"
|
|
selectedChannelIndex = cursor - 1;
|
|
}
|
|
break;
|
|
|
|
case NEXT_TILE:
|
|
inkhud->nextTile();
|
|
// Unselect menu item after tile change
|
|
cursorShown = false;
|
|
cursor = 0;
|
|
break;
|
|
|
|
case SEND_PING:
|
|
service->refreshLocalMeshNode();
|
|
service->trySendPosition(NODENUM_BROADCAST, true);
|
|
|
|
// Force the next refresh to use FULL, to protect the display, as some users will probably spam this button
|
|
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL);
|
|
break;
|
|
|
|
case FREE_TEXT:
|
|
OSThread::enabled = false;
|
|
handleFreeText = true;
|
|
cm.freeTextItem.rawText.erase(); // clear the previous freetext message
|
|
freeTextMode = true; // render input field instead of normal menu
|
|
if (supportsFreeTextKeyboard(inkhud, settings))
|
|
inkhud->openKeyboard();
|
|
break;
|
|
|
|
case STORE_CANNEDMESSAGE_SELECTION: {
|
|
const uint8_t prefixItems = supportsFreeTextKeyboard(inkhud, settings) ? 2 : 1;
|
|
cm.selectedMessageItem = &cm.messageItems.at(cursor - prefixItems);
|
|
} break;
|
|
|
|
case SEND_CANNEDMESSAGE:
|
|
cm.selectedRecipientItem = &cm.recipientItems.at(cursor);
|
|
// send selected message
|
|
sendText(cm.selectedRecipientItem->dest, cm.selectedRecipientItem->channelIndex, cm.selectedMessageItem->rawText.c_str());
|
|
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL); // Next refresh should be FULL. Lots of button pressing to get here
|
|
break;
|
|
|
|
case ROTATE:
|
|
inkhud->rotate();
|
|
break;
|
|
|
|
case ALIGN_JOYSTICK:
|
|
inkhud->openAlignStick();
|
|
break;
|
|
|
|
case LAYOUT:
|
|
// Todo: smarter incrementing of tile count
|
|
settings->userTiles.count++;
|
|
|
|
if (settings->userTiles.count == 3) // Skip 3 tiles: not done yet
|
|
settings->userTiles.count++;
|
|
|
|
if (settings->userTiles.count > settings->userTiles.maxCount) // Loop around if tile count now too high
|
|
settings->userTiles.count = 1;
|
|
|
|
inkhud->updateLayout();
|
|
break;
|
|
|
|
case TOGGLE_APPLET:
|
|
if (item.checkState) {
|
|
*item.checkState = !(*item.checkState);
|
|
inkhud->updateAppletSelection();
|
|
}
|
|
break;
|
|
|
|
case TOGGLE_AUTOSHOW_APPLET:
|
|
// Toggle settings.userApplets.autoshow[] value, via MenuItem::checkState pointer set in populateAutoshowPage()
|
|
if (item.checkState) {
|
|
*item.checkState = !(*item.checkState);
|
|
}
|
|
break;
|
|
|
|
case TOGGLE_NOTIFICATIONS:
|
|
if (item.checkState) {
|
|
*item.checkState = !(*item.checkState);
|
|
}
|
|
break;
|
|
|
|
case TOGGLE_INVERT_COLOR:
|
|
if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED)
|
|
config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT;
|
|
else
|
|
config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_INVERTED;
|
|
|
|
nodeDB->saveToDisk(SEGMENT_CONFIG);
|
|
break;
|
|
|
|
case SET_RECENTS: {
|
|
// cursor - 1 because index 0 is "Back"
|
|
const uint8_t index = cursor - 1;
|
|
constexpr uint8_t optionCount = sizeof(RECENTS_OPTIONS_MINUTES) / sizeof(RECENTS_OPTIONS_MINUTES[0]);
|
|
assert(index < optionCount);
|
|
settings->recentlyActiveSeconds = RECENTS_OPTIONS_MINUTES[index] * 60;
|
|
break;
|
|
}
|
|
|
|
case SHUTDOWN:
|
|
LOG_INFO("Shutting down from menu");
|
|
shutdownAtMsec = millis();
|
|
// Menu is then sent to background via onShutdown
|
|
break;
|
|
|
|
case TOGGLE_BATTERY_ICON:
|
|
inkhud->toggleBatteryIcon();
|
|
break;
|
|
|
|
case TOGGLE_BACKLIGHT:
|
|
// Note: backlight is already on in this situation.
|
|
// This toggle controls whether it should remain on when menu closes.
|
|
#if defined(T5_S3_EPAPER_PRO)
|
|
{
|
|
const bool keepOn = !t5BacklightIsUserEnabled();
|
|
t5BacklightSetUserEnabled(keepOn);
|
|
saveT5BacklightKeepOn(keepOn);
|
|
if (item.checkState)
|
|
*(item.checkState) = keepOn;
|
|
}
|
|
#else
|
|
if (!backlight)
|
|
backlight = Drivers::LatchingBacklight::getInstance();
|
|
if (backlight->isLatched())
|
|
backlight->off();
|
|
else
|
|
backlight->latch();
|
|
if (item.checkState)
|
|
*(item.checkState) = backlight->isLatched();
|
|
#endif
|
|
break;
|
|
|
|
case TOGGLE_12H_CLOCK:
|
|
config.display.use_12h_clock = !config.display.use_12h_clock;
|
|
nodeDB->saveToDisk(SEGMENT_CONFIG);
|
|
break;
|
|
|
|
case TOGGLE_GPS:
|
|
#if !MESHTASTIC_EXCLUDE_GPS && HAS_GPS
|
|
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_DISABLED) {
|
|
config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED;
|
|
} else if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
|
|
config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_DISABLED;
|
|
} else {
|
|
// NOT_PRESENT do nothing
|
|
break;
|
|
}
|
|
nodeDB->saveToDisk(SEGMENT_CONFIG);
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
#endif
|
|
break;
|
|
|
|
case TOGGLE_SMART_POSITION:
|
|
config.position.position_broadcast_smart_enabled = !config.position.position_broadcast_smart_enabled;
|
|
applyConfigReload(SEGMENT_CONFIG, true);
|
|
break;
|
|
|
|
case SET_POSITION_BROADCAST_INTERVAL: {
|
|
const uint8_t index = cursor - 1;
|
|
constexpr uint8_t optionCount = sizeof(POSITION_BROADCAST_OPTIONS) / sizeof(POSITION_BROADCAST_OPTIONS[0]);
|
|
if (index < optionCount && config.position.position_broadcast_secs != POSITION_BROADCAST_OPTIONS[index].value) {
|
|
config.position.position_broadcast_secs = POSITION_BROADCAST_OPTIONS[index].value;
|
|
applyConfigReload(SEGMENT_CONFIG, true);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case SET_SMART_BROADCAST_INTERVAL: {
|
|
const uint8_t index = cursor - 1;
|
|
constexpr uint8_t optionCount = sizeof(SMART_INTERVAL_OPTIONS) / sizeof(SMART_INTERVAL_OPTIONS[0]);
|
|
if (index < optionCount && config.position.broadcast_smart_minimum_interval_secs != SMART_INTERVAL_OPTIONS[index].value) {
|
|
config.position.broadcast_smart_minimum_interval_secs = SMART_INTERVAL_OPTIONS[index].value;
|
|
applyConfigReload(SEGMENT_CONFIG, true);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case SET_SMART_BROADCAST_DISTANCE: {
|
|
const uint8_t index = cursor - 1;
|
|
constexpr uint8_t optionCount = sizeof(SMART_DISTANCE_OPTIONS) / sizeof(SMART_DISTANCE_OPTIONS[0]);
|
|
if (index < optionCount && config.position.broadcast_smart_minimum_distance != SMART_DISTANCE_OPTIONS[index]) {
|
|
config.position.broadcast_smart_minimum_distance = SMART_DISTANCE_OPTIONS[index];
|
|
applyConfigReload(SEGMENT_CONFIG, true);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case SET_GPS_UPDATE_INTERVAL: {
|
|
const uint8_t index = cursor - 1;
|
|
constexpr uint8_t optionCount = sizeof(GPS_UPDATE_INTERVAL_OPTIONS) / sizeof(GPS_UPDATE_INTERVAL_OPTIONS[0]);
|
|
if (index < optionCount && config.position.gps_update_interval != GPS_UPDATE_INTERVAL_OPTIONS[index].value) {
|
|
config.position.gps_update_interval = GPS_UPDATE_INTERVAL_OPTIONS[index].value;
|
|
applyConfigReload(SEGMENT_CONFIG, true);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case ENABLE_BLUETOOTH:
|
|
// This helps users recover from a bad wifi config
|
|
LOG_INFO("Enabling Bluetooth");
|
|
config.network.wifi_enabled = false;
|
|
config.bluetooth.enabled = true;
|
|
nodeDB->saveToDisk(SEGMENT_CONFIG);
|
|
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
|
|
rebootAtMsec = millis() + 2000;
|
|
break;
|
|
|
|
// Power / Network (ESP32-only)
|
|
#if defined(ARCH_ESP32)
|
|
case TOGGLE_POWER_SAVE:
|
|
config.power.is_power_saving = !config.power.is_power_saving;
|
|
nodeDB->saveToDisk(SEGMENT_CONFIG);
|
|
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
|
|
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
|
break;
|
|
|
|
case TOGGLE_WIFI:
|
|
config.network.wifi_enabled = !config.network.wifi_enabled;
|
|
|
|
if (config.network.wifi_enabled) {
|
|
// Switch behavior: WiFi ON forces Bluetooth OFF
|
|
config.bluetooth.enabled = false;
|
|
}
|
|
|
|
nodeDB->saveToDisk(SEGMENT_CONFIG);
|
|
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
|
|
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
|
break;
|
|
#endif
|
|
// ADC Calibration
|
|
case CALIBRATE_ADC: {
|
|
// Read current measured voltage
|
|
float measuredV = powerStatus->getBatteryVoltageMv() / 1000.0f;
|
|
|
|
// Sanity check
|
|
if (measuredV < 3.0f || measuredV > 4.5f) {
|
|
LOG_WARN("ADC calibration aborted, unreasonable voltage: %.2fV", measuredV);
|
|
break;
|
|
}
|
|
|
|
// Determine the base multiplier currently in effect
|
|
float baseMult = 0.0f;
|
|
|
|
if (config.power.adc_multiplier_override > 0.0f) {
|
|
baseMult = config.power.adc_multiplier_override;
|
|
}
|
|
#ifdef ADC_MULTIPLIER
|
|
else {
|
|
baseMult = ADC_MULTIPLIER;
|
|
}
|
|
#endif
|
|
|
|
if (baseMult <= 0.0f) {
|
|
LOG_WARN("ADC calibration failed: no base multiplier");
|
|
break;
|
|
}
|
|
|
|
// Target voltage considered 100% by UI
|
|
constexpr float TARGET_VOLTAGE = 4.19f;
|
|
|
|
// Calculate new multiplier
|
|
float newMult = baseMult * (TARGET_VOLTAGE / measuredV);
|
|
|
|
config.power.adc_multiplier_override = newMult;
|
|
|
|
nodeDB->saveToDisk(SEGMENT_CONFIG);
|
|
|
|
LOG_INFO("ADC calibrated: measured=%.3fV base=%.4f new=%.4f", measuredV, baseMult, newMult);
|
|
|
|
break;
|
|
}
|
|
|
|
// Display
|
|
case SET_DISPLAY_TIMEOUT: {
|
|
// cursor - 1 because index 0 is "Back"
|
|
const uint8_t index = cursor - 1;
|
|
constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]);
|
|
if (index < optionCount) {
|
|
config.display.screen_on_secs = DISPLAY_TIMEOUT_OPTIONS[index].seconds;
|
|
nodeDB->saveToDisk(SEGMENT_CONFIG);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case TOGGLE_DISPLAY_UNITS:
|
|
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL)
|
|
config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_METRIC;
|
|
else
|
|
config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL;
|
|
|
|
nodeDB->saveToDisk(SEGMENT_CONFIG);
|
|
break;
|
|
|
|
// Bluetooth
|
|
case TOGGLE_BLUETOOTH:
|
|
config.bluetooth.enabled = !config.bluetooth.enabled;
|
|
|
|
if (config.bluetooth.enabled) {
|
|
// Switch behavior: Bluetooth ON forces WiFi OFF
|
|
config.network.wifi_enabled = false;
|
|
}
|
|
|
|
nodeDB->saveToDisk(SEGMENT_CONFIG);
|
|
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
|
|
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
|
break;
|
|
|
|
case TOGGLE_BLUETOOTH_PAIR_MODE:
|
|
config.bluetooth.fixed_pin = !config.bluetooth.fixed_pin;
|
|
nodeDB->saveToDisk(SEGMENT_CONFIG);
|
|
break;
|
|
|
|
// Regions
|
|
case SET_REGION_US:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
|
break;
|
|
|
|
case SET_REGION_EU_868:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
|
|
break;
|
|
|
|
case SET_REGION_EU_433:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_433);
|
|
break;
|
|
|
|
case SET_REGION_CN:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_CN);
|
|
break;
|
|
|
|
case SET_REGION_JP:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_JP);
|
|
break;
|
|
|
|
case SET_REGION_ANZ:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ANZ);
|
|
break;
|
|
case SET_REGION_KR:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_KR);
|
|
break;
|
|
|
|
case SET_REGION_TW:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_TW);
|
|
break;
|
|
|
|
case SET_REGION_RU:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_RU);
|
|
break;
|
|
|
|
case SET_REGION_IN:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_IN);
|
|
break;
|
|
|
|
case SET_REGION_NZ_865:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_NZ_865);
|
|
break;
|
|
|
|
case SET_REGION_TH:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_TH);
|
|
break;
|
|
|
|
case SET_REGION_LORA_24:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
|
|
break;
|
|
|
|
case SET_REGION_UA_433:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_UA_433);
|
|
break;
|
|
|
|
case SET_REGION_MY_433:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_MY_433);
|
|
break;
|
|
|
|
case SET_REGION_MY_919:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_MY_919);
|
|
break;
|
|
|
|
case SET_REGION_SG_923:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_SG_923);
|
|
break;
|
|
|
|
case SET_REGION_PH_433:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_PH_433);
|
|
break;
|
|
|
|
case SET_REGION_PH_868:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_PH_868);
|
|
break;
|
|
|
|
case SET_REGION_PH_915:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_PH_915);
|
|
break;
|
|
|
|
case SET_REGION_ANZ_433:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ANZ_433);
|
|
break;
|
|
|
|
case SET_REGION_KZ_433:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_KZ_433);
|
|
break;
|
|
|
|
case SET_REGION_KZ_863:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_KZ_863);
|
|
break;
|
|
|
|
case SET_REGION_NP_865:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_NP_865);
|
|
break;
|
|
|
|
case SET_REGION_BR_902:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_BR_902);
|
|
break;
|
|
|
|
case SET_REGION_EU_866:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_866);
|
|
break;
|
|
|
|
case SET_REGION_NARROW_868:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868);
|
|
break;
|
|
|
|
case SET_REGION_ITU1_2M:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M);
|
|
break;
|
|
|
|
case SET_REGION_ITU2_2M:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU2_2M);
|
|
break;
|
|
|
|
case SET_REGION_ITU3_2M:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU3_2M);
|
|
break;
|
|
|
|
case SET_REGION_ITU2_125CM:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM);
|
|
break;
|
|
|
|
case SET_REGION_ITU1_70CM:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU1_70CM);
|
|
break;
|
|
|
|
case SET_REGION_ITU2_70CM:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU2_70CM);
|
|
break;
|
|
|
|
case SET_REGION_ITU3_70CM:
|
|
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU3_70CM);
|
|
break;
|
|
|
|
// Roles
|
|
case SET_ROLE_CLIENT:
|
|
applyDeviceRole(meshtastic_Config_DeviceConfig_Role_CLIENT);
|
|
break;
|
|
|
|
case SET_ROLE_CLIENT_MUTE:
|
|
applyDeviceRole(meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE);
|
|
break;
|
|
|
|
case SET_ROLE_ROUTER:
|
|
applyDeviceRole(meshtastic_Config_DeviceConfig_Role_ROUTER);
|
|
break;
|
|
|
|
case SET_ROLE_REPEATER:
|
|
applyDeviceRole(meshtastic_Config_DeviceConfig_Role_REPEATER);
|
|
break;
|
|
|
|
// Presets
|
|
case SET_PRESET_LONG_SLOW:
|
|
applyLoRaPreset(PRESET(LONG_SLOW));
|
|
break;
|
|
|
|
case SET_PRESET_LONG_MODERATE:
|
|
applyLoRaPreset(PRESET(LONG_MODERATE));
|
|
break;
|
|
|
|
case SET_PRESET_LONG_FAST:
|
|
applyLoRaPreset(PRESET(LONG_FAST));
|
|
break;
|
|
|
|
case SET_PRESET_MEDIUM_SLOW:
|
|
applyLoRaPreset(PRESET(MEDIUM_SLOW));
|
|
break;
|
|
|
|
case SET_PRESET_MEDIUM_FAST:
|
|
applyLoRaPreset(PRESET(MEDIUM_FAST));
|
|
break;
|
|
|
|
case SET_PRESET_SHORT_SLOW:
|
|
applyLoRaPreset(PRESET(SHORT_SLOW));
|
|
break;
|
|
|
|
case SET_PRESET_SHORT_FAST:
|
|
applyLoRaPreset(PRESET(SHORT_FAST));
|
|
break;
|
|
|
|
case SET_PRESET_SHORT_TURBO:
|
|
applyLoRaPreset(PRESET(SHORT_TURBO));
|
|
break;
|
|
|
|
case SET_PRESET_NARROW_SLOW:
|
|
applyLoRaPreset(PRESET(NARROW_SLOW));
|
|
break;
|
|
|
|
case SET_PRESET_NARROW_FAST:
|
|
applyLoRaPreset(PRESET(NARROW_FAST));
|
|
break;
|
|
|
|
case SET_PRESET_TINY_SLOW:
|
|
applyLoRaPreset(PRESET(TINY_SLOW));
|
|
break;
|
|
|
|
case SET_PRESET_TINY_FAST:
|
|
applyLoRaPreset(PRESET(TINY_FAST));
|
|
break;
|
|
|
|
case SET_PRESET_FROM_REGION: {
|
|
// cursor - 1 because index 0 is "Back"
|
|
const uint8_t index = cursor - 1;
|
|
if (index < regionPresetCount) {
|
|
applyLoRaPreset(regionPresets[index]);
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Timezones
|
|
case SET_TZ_US_HAWAII:
|
|
applyTimezone("HST10");
|
|
break;
|
|
|
|
case SET_TZ_US_ALASKA:
|
|
applyTimezone("AKST9AKDT,M3.2.0,M11.1.0");
|
|
break;
|
|
|
|
case SET_TZ_US_PACIFIC:
|
|
applyTimezone("PST8PDT,M3.2.0,M11.1.0");
|
|
break;
|
|
|
|
case SET_TZ_US_ARIZONA:
|
|
applyTimezone("MST7");
|
|
break;
|
|
|
|
case SET_TZ_US_MOUNTAIN:
|
|
applyTimezone("MST7MDT,M3.2.0,M11.1.0");
|
|
break;
|
|
|
|
case SET_TZ_US_CENTRAL:
|
|
applyTimezone("CST6CDT,M3.2.0,M11.1.0");
|
|
break;
|
|
|
|
case SET_TZ_US_EASTERN:
|
|
applyTimezone("EST5EDT,M3.2.0,M11.1.0");
|
|
break;
|
|
|
|
case SET_TZ_BR_BRAZILIA:
|
|
applyTimezone("BRT3");
|
|
break;
|
|
|
|
case SET_TZ_UTC:
|
|
applyTimezone("UTC0");
|
|
break;
|
|
|
|
case SET_TZ_EU_WESTERN:
|
|
applyTimezone("GMT0BST,M3.5.0/1,M10.5.0");
|
|
break;
|
|
|
|
case SET_TZ_EU_CENTRAL:
|
|
applyTimezone("CET-1CEST,M3.5.0,M10.5.0/3");
|
|
break;
|
|
|
|
case SET_TZ_EU_EASTERN:
|
|
applyTimezone("EET-2EEST,M3.5.0/3,M10.5.0/4");
|
|
break;
|
|
|
|
case SET_TZ_ASIA_KOLKATA:
|
|
applyTimezone("IST-5:30");
|
|
break;
|
|
|
|
case SET_TZ_ASIA_HONG_KONG:
|
|
applyTimezone("HKT-8");
|
|
break;
|
|
|
|
case SET_TZ_AU_AWST:
|
|
applyTimezone("AWST-8");
|
|
break;
|
|
|
|
case SET_TZ_AU_ACST:
|
|
applyTimezone("ACST-9:30ACDT,M10.1.0,M4.1.0/3");
|
|
break;
|
|
|
|
case SET_TZ_AU_AEST:
|
|
applyTimezone("AEST-10AEDT,M10.1.0,M4.1.0/3");
|
|
break;
|
|
|
|
case SET_TZ_PACIFIC_NZ:
|
|
applyTimezone("NZST-12NZDT,M9.5.0,M4.1.0/3");
|
|
break;
|
|
|
|
// Channels
|
|
case TOGGLE_CHANNEL_UPLINK: {
|
|
auto &ch = channels.getByIndex(selectedChannelIndex);
|
|
ch.settings.uplink_enabled = !ch.settings.uplink_enabled;
|
|
nodeDB->saveToDisk(SEGMENT_CHANNELS);
|
|
service->reloadConfig(SEGMENT_CHANNELS);
|
|
break;
|
|
}
|
|
|
|
case TOGGLE_CHANNEL_DOWNLINK: {
|
|
auto &ch = channels.getByIndex(selectedChannelIndex);
|
|
ch.settings.downlink_enabled = !ch.settings.downlink_enabled;
|
|
nodeDB->saveToDisk(SEGMENT_CHANNELS);
|
|
service->reloadConfig(SEGMENT_CHANNELS);
|
|
break;
|
|
}
|
|
|
|
case TOGGLE_CHANNEL_POSITION: {
|
|
auto &ch = channels.getByIndex(selectedChannelIndex);
|
|
|
|
if (!ch.settings.has_module_settings)
|
|
ch.settings.has_module_settings = true;
|
|
|
|
if (ch.settings.module_settings.position_precision > 0)
|
|
ch.settings.module_settings.position_precision = 0;
|
|
else
|
|
ch.settings.module_settings.position_precision = 13; // default
|
|
|
|
nodeDB->saveToDisk(SEGMENT_CHANNELS);
|
|
service->reloadConfig(SEGMENT_CHANNELS);
|
|
break;
|
|
}
|
|
|
|
case SET_CHANNEL_PRECISION: {
|
|
auto &ch = channels.getByIndex(selectedChannelIndex);
|
|
|
|
if (!ch.settings.has_module_settings)
|
|
ch.settings.has_module_settings = true;
|
|
|
|
// Cursor - 1 because of "Back"
|
|
uint8_t index = cursor - 1;
|
|
|
|
constexpr uint8_t optionCount = sizeof(POSITION_PRECISION_OPTIONS) / sizeof(POSITION_PRECISION_OPTIONS[0]);
|
|
|
|
if (index < optionCount) {
|
|
ch.settings.module_settings.position_precision = POSITION_PRECISION_OPTIONS[index].value;
|
|
}
|
|
|
|
nodeDB->saveToDisk(SEGMENT_CHANNELS);
|
|
service->reloadConfig(SEGMENT_CHANNELS);
|
|
break;
|
|
}
|
|
|
|
case RESET_NODEDB_ALL:
|
|
InkHUD::getInstance()->notifyApplyingChanges();
|
|
nodeDB->resetNodes();
|
|
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
|
break;
|
|
|
|
case RESET_NODEDB_KEEP_FAVORITES:
|
|
InkHUD::getInstance()->notifyApplyingChanges();
|
|
nodeDB->resetNodes(1);
|
|
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
|
break;
|
|
|
|
case WIPE_MESSAGES_ALL:
|
|
LOG_INFO("Wiping all messages from menu");
|
|
messageStore.clearAllMessages();
|
|
inkhud->persistence->loadLatestMessage();
|
|
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL, true);
|
|
break;
|
|
|
|
case MAP_ZOOM_IN: {
|
|
MapApplet *mapApplet = borrowedTileOwner ? borrowedTileOwner->asMapApplet() : nullptr;
|
|
if (mapApplet)
|
|
mapApplet->zoomIn();
|
|
break;
|
|
}
|
|
|
|
case MAP_ZOOM_OUT: {
|
|
MapApplet *mapApplet = borrowedTileOwner ? borrowedTileOwner->asMapApplet() : nullptr;
|
|
if (mapApplet)
|
|
mapApplet->zoomOut();
|
|
break;
|
|
}
|
|
|
|
case MAP_ZOOM_RESET: {
|
|
MapApplet *mapApplet = borrowedTileOwner ? borrowedTileOwner->asMapApplet() : nullptr;
|
|
if (mapApplet)
|
|
mapApplet->resetZoom();
|
|
break;
|
|
}
|
|
|
|
default:
|
|
LOG_WARN("Action not implemented");
|
|
}
|
|
|
|
// Move to next page, as defined for the MenuItem
|
|
showPage(item.nextPage);
|
|
}
|
|
|
|
// Display a new page of MenuItems
|
|
// May reload same page, or exit menu applet entirely
|
|
// Fills the MenuApplet::items vector
|
|
void InkHUD::MenuApplet::showPage(MenuPage page)
|
|
{
|
|
items.clear();
|
|
items.shrink_to_fit();
|
|
nodeConfigLabels.clear();
|
|
|
|
switch (page) {
|
|
case ROOT:
|
|
previousPage = MenuPage::EXIT;
|
|
// Optional: next applet
|
|
if (settings->optionalMenuItems.nextTile && settings->userTiles.count > 1)
|
|
items.push_back(MenuItem("Next Tile", MenuAction::NEXT_TILE, MenuPage::ROOT)); // Only if multiple applets shown
|
|
|
|
items.push_back(MenuItem("Send", MenuPage::SEND));
|
|
|
|
// Map zoom controls - only when viewing a map applet
|
|
{
|
|
MapApplet *mapApplet = borrowedTileOwner ? borrowedTileOwner->asMapApplet() : nullptr;
|
|
if (mapApplet) {
|
|
if (mapApplet->canZoomIn())
|
|
items.push_back(MenuItem("Zoom In", MenuAction::MAP_ZOOM_IN, MenuPage::EXIT));
|
|
if (mapApplet->canZoomOut())
|
|
items.push_back(MenuItem("Zoom Out", MenuAction::MAP_ZOOM_OUT, MenuPage::EXIT));
|
|
if (mapApplet->isZoomLocked())
|
|
items.push_back(MenuItem("Reset Zoom", MenuAction::MAP_ZOOM_RESET, MenuPage::EXIT));
|
|
}
|
|
}
|
|
|
|
items.push_back(MenuItem("Options", MenuPage::OPTIONS));
|
|
// items.push_back(MenuItem("Display Off", MenuPage::EXIT)); // TODO
|
|
items.push_back(MenuItem("Node Config", MenuPage::NODE_CONFIG));
|
|
items.push_back(MenuItem("Save & Shut Down", MenuAction::SHUTDOWN));
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
|
|
case SEND:
|
|
populateSendPage();
|
|
previousPage = MenuPage::ROOT;
|
|
break;
|
|
|
|
case CANNEDMESSAGE_RECIPIENT:
|
|
populateRecipientPage();
|
|
previousPage = MenuPage::SEND;
|
|
break;
|
|
|
|
case OPTIONS:
|
|
previousPage = MenuPage::ROOT;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
// Optional: backlight
|
|
if (settings->optionalMenuItems.backlight) {
|
|
#if defined(T5_S3_EPAPER_PRO)
|
|
keepBacklightOn = t5BacklightIsUserEnabled();
|
|
#else
|
|
if (!backlight)
|
|
backlight = Drivers::LatchingBacklight::getInstance();
|
|
keepBacklightOn = backlight->isLatched();
|
|
#endif
|
|
items.push_back(MenuItem("Keep Backlight On", MenuAction::TOGGLE_BACKLIGHT, MenuPage::OPTIONS, &keepBacklightOn));
|
|
}
|
|
|
|
// Options Toggles
|
|
items.push_back(MenuItem("Applets", MenuPage::APPLETS));
|
|
items.push_back(MenuItem("Auto-show", MenuPage::AUTOSHOW));
|
|
items.push_back(MenuItem("Recents Duration", MenuPage::RECENTS));
|
|
if (settings->userTiles.maxCount > 1)
|
|
items.push_back(MenuItem("Layout", MenuAction::LAYOUT, MenuPage::OPTIONS));
|
|
items.push_back(MenuItem("Rotate", MenuAction::ROTATE, MenuPage::OPTIONS));
|
|
if (settings->joystick.enabled && !inkhud->twoWayRocker)
|
|
items.push_back(MenuItem("Align Joystick", MenuAction::ALIGN_JOYSTICK, MenuPage::EXIT));
|
|
items.push_back(MenuItem("Notifications", MenuAction::TOGGLE_NOTIFICATIONS, MenuPage::OPTIONS,
|
|
&settings->optionalFeatures.notifications));
|
|
items.push_back(MenuItem("Battery Icon", MenuAction::TOGGLE_BATTERY_ICON, MenuPage::OPTIONS,
|
|
&settings->optionalFeatures.batteryIcon));
|
|
invertedColors = (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED);
|
|
items.push_back(MenuItem("Invert Color", MenuAction::TOGGLE_INVERT_COLOR, MenuPage::OPTIONS, &invertedColors));
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
|
|
case APPLETS:
|
|
previousPage = MenuPage::OPTIONS;
|
|
populateAppletPage(); // must be first
|
|
items.insert(items.begin(), MenuItem("Back", previousPage));
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
|
|
case AUTOSHOW:
|
|
previousPage = MenuPage::OPTIONS;
|
|
populateAutoshowPage(); // must be first
|
|
items.insert(items.begin(), MenuItem("Back", previousPage));
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
|
|
case RECENTS:
|
|
previousPage = MenuPage::OPTIONS;
|
|
populateRecentsPage(); // builds only the options
|
|
items.insert(items.begin(), MenuItem("Back", previousPage));
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
|
|
case NODE_CONFIG:
|
|
previousPage = MenuPage::ROOT;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
// Radio Config Section
|
|
items.push_back(MenuItem::Header("Radio Config"));
|
|
items.push_back(MenuItem("LoRa", MenuPage::NODE_CONFIG_LORA));
|
|
items.push_back(MenuItem("Channel", MenuPage::NODE_CONFIG_CHANNELS));
|
|
// Device Config Section
|
|
items.push_back(MenuItem::Header("Device Config"));
|
|
items.push_back(MenuItem("Device", MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("Position", MenuPage::NODE_CONFIG_POSITION));
|
|
items.push_back(MenuItem("Power", MenuPage::NODE_CONFIG_POWER));
|
|
#if defined(ARCH_ESP32)
|
|
items.push_back(MenuItem("Network", MenuPage::NODE_CONFIG_NETWORK));
|
|
#endif
|
|
items.push_back(MenuItem("Display", MenuPage::NODE_CONFIG_DISPLAY));
|
|
items.push_back(MenuItem("Bluetooth", MenuPage::NODE_CONFIG_BLUETOOTH));
|
|
|
|
// Administration Section
|
|
items.push_back(MenuItem::Header("Administration"));
|
|
items.push_back(MenuItem("Reset NodeDB", MenuPage::NODE_CONFIG_ADMIN_RESET));
|
|
items.push_back(MenuItem("Wipe Messages", MenuPage::NODE_CONFIG_ADMIN_MESSAGES));
|
|
|
|
// Exit
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
|
|
case NODE_CONFIG_DEVICE: {
|
|
previousPage = MenuPage::NODE_CONFIG;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
|
|
const char *role = DisplayFormatters::getDeviceRole(config.device.role);
|
|
nodeConfigLabels.emplace_back("Role: " + std::string(role));
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_DEVICE_ROLE));
|
|
|
|
const char *tzLabel = getTimezoneLabelFromValue(config.device.tzdef);
|
|
nodeConfigLabels.emplace_back("Timezone: " + std::string(tzLabel));
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::TIMEZONE));
|
|
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
}
|
|
|
|
case NODE_CONFIG_POSITION: {
|
|
previousPage = MenuPage::NODE_CONFIG;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
|
|
items.push_back(MenuItem::Header("Device GPS"));
|
|
|
|
#if !MESHTASTIC_EXCLUDE_GPS && HAS_GPS
|
|
const auto mode = config.position.gps_mode;
|
|
if (mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) {
|
|
items.push_back(MenuItem("GPS None", MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_POSITION));
|
|
} else {
|
|
gpsEnabled = (mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED);
|
|
items.push_back(MenuItem("GPS", MenuAction::TOGGLE_GPS, MenuPage::NODE_CONFIG_POSITION, &gpsEnabled));
|
|
|
|
nodeConfigLabels.emplace_back(
|
|
"GPS Poll: " + getUInt32OptionLabel(GPS_UPDATE_INTERVAL_OPTIONS,
|
|
sizeof(GPS_UPDATE_INTERVAL_OPTIONS) / sizeof(GPS_UPDATE_INTERVAL_OPTIONS[0]),
|
|
config.position.gps_update_interval, "Default"));
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION,
|
|
MenuPage::NODE_CONFIG_POSITION_GPS_UPDATE_INTERVAL));
|
|
}
|
|
#endif
|
|
|
|
items.push_back(MenuItem::Header("Position Packet"));
|
|
|
|
nodeConfigLabels.emplace_back(
|
|
"Broadcast: " + getUInt32OptionLabel(POSITION_BROADCAST_OPTIONS,
|
|
sizeof(POSITION_BROADCAST_OPTIONS) / sizeof(POSITION_BROADCAST_OPTIONS[0]),
|
|
config.position.position_broadcast_secs));
|
|
items.push_back(
|
|
MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_POSITION_BROADCAST_INTERVAL));
|
|
|
|
items.push_back(MenuItem("Smart Pos", MenuAction::TOGGLE_SMART_POSITION, MenuPage::NODE_CONFIG_POSITION,
|
|
&config.position.position_broadcast_smart_enabled));
|
|
|
|
if (config.position.position_broadcast_smart_enabled) {
|
|
nodeConfigLabels.emplace_back("Smart Int: " +
|
|
getUInt32OptionLabel(SMART_INTERVAL_OPTIONS,
|
|
sizeof(SMART_INTERVAL_OPTIONS) / sizeof(SMART_INTERVAL_OPTIONS[0]),
|
|
config.position.broadcast_smart_minimum_interval_secs));
|
|
items.push_back(
|
|
MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_POSITION_SMART_INTERVAL));
|
|
|
|
nodeConfigLabels.emplace_back("Smart Dist: " + localizeDistance(config.position.broadcast_smart_minimum_distance));
|
|
items.push_back(
|
|
MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_POSITION_SMART_DISTANCE));
|
|
}
|
|
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
}
|
|
|
|
case NODE_CONFIG_POSITION_BROADCAST_INTERVAL:
|
|
previousPage = MenuPage::NODE_CONFIG_POSITION;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
items.push_back(MenuItem::Header("Max time between sends"));
|
|
for (const auto &option : POSITION_BROADCAST_OPTIONS) {
|
|
nodeConfigLabels.emplace_back(option.label);
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_POSITION_BROADCAST_INTERVAL,
|
|
MenuPage::NODE_CONFIG_POSITION));
|
|
}
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
|
|
case NODE_CONFIG_POSITION_SMART_INTERVAL:
|
|
previousPage = MenuPage::NODE_CONFIG_POSITION;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
items.push_back(MenuItem::Header("Fastest smart resend"));
|
|
for (const auto &option : SMART_INTERVAL_OPTIONS) {
|
|
nodeConfigLabels.emplace_back(option.label);
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_SMART_BROADCAST_INTERVAL,
|
|
MenuPage::NODE_CONFIG_POSITION));
|
|
}
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
|
|
case NODE_CONFIG_POSITION_SMART_DISTANCE:
|
|
previousPage = MenuPage::NODE_CONFIG_POSITION;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
items.push_back(MenuItem::Header("Move this far to send"));
|
|
for (const auto &option : SMART_DISTANCE_OPTIONS) {
|
|
nodeConfigLabels.emplace_back(localizeDistance(option));
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_SMART_BROADCAST_DISTANCE,
|
|
MenuPage::NODE_CONFIG_POSITION));
|
|
}
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
|
|
case NODE_CONFIG_POSITION_GPS_UPDATE_INTERVAL:
|
|
previousPage = MenuPage::NODE_CONFIG_POSITION;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
items.push_back(MenuItem::Header("GPS poll cadence"));
|
|
for (const auto &option : GPS_UPDATE_INTERVAL_OPTIONS) {
|
|
nodeConfigLabels.emplace_back(option.label);
|
|
items.push_back(
|
|
MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_GPS_UPDATE_INTERVAL, MenuPage::NODE_CONFIG_POSITION));
|
|
}
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
|
|
case NODE_CONFIG_POWER: {
|
|
previousPage = MenuPage::NODE_CONFIG;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
#if defined(ARCH_ESP32)
|
|
items.push_back(MenuItem("Powersave", MenuAction::TOGGLE_POWER_SAVE, MenuPage::EXIT, &config.power.is_power_saving));
|
|
#endif
|
|
// ADC Multiplier
|
|
float effectiveMult = 0.0f;
|
|
|
|
// User override always shows if it exists
|
|
if (config.power.adc_multiplier_override > 0.0f) {
|
|
effectiveMult = config.power.adc_multiplier_override;
|
|
}
|
|
#ifdef ADC_MULTIPLIER
|
|
else {
|
|
// Fallback to variant defined
|
|
effectiveMult = ADC_MULTIPLIER;
|
|
}
|
|
#endif
|
|
|
|
// Only show if we actually have a value
|
|
if (effectiveMult > 0.0f) {
|
|
char buf[32];
|
|
snprintf(buf, sizeof(buf), "ADC Mult: %.3f", effectiveMult);
|
|
nodeConfigLabels.emplace_back(buf);
|
|
|
|
items.push_back(
|
|
MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_POWER_ADC_CAL));
|
|
}
|
|
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
}
|
|
|
|
case NODE_CONFIG_POWER_ADC_CAL: {
|
|
previousPage = MenuPage::NODE_CONFIG_POWER;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
|
|
// Instruction text (header-style, non-selectable)
|
|
items.push_back(MenuItem::Header("Run on full charge Only"));
|
|
|
|
// Action
|
|
items.push_back(MenuItem("Calibrate ADC", MenuAction::CALIBRATE_ADC, MenuPage::NODE_CONFIG_POWER));
|
|
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
}
|
|
|
|
case NODE_CONFIG_NETWORK: {
|
|
previousPage = MenuPage::NODE_CONFIG;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
|
|
const char *wifiLabel = config.network.wifi_enabled ? "WiFi: On" : "WiFi: Off";
|
|
|
|
items.push_back(MenuItem(wifiLabel, MenuAction::TOGGLE_WIFI, MenuPage::EXIT));
|
|
|
|
#if HAS_WIFI && defined(ARCH_ESP32)
|
|
if (config.network.wifi_enabled) {
|
|
|
|
// Status
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
nodeConfigLabels.emplace_back("Status: Connected");
|
|
} else {
|
|
nodeConfigLabels.emplace_back("Status: Not Connected");
|
|
}
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_NETWORK));
|
|
|
|
// Signal
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
int rssi = WiFi.RSSI();
|
|
int quality = constrain(2 * (rssi + 100), 0, 100);
|
|
|
|
char sigBuf[32];
|
|
snprintf(sigBuf, sizeof(sigBuf), "Signal: %d%%", quality);
|
|
nodeConfigLabels.emplace_back(sigBuf);
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_NETWORK));
|
|
|
|
char ipBuf[64];
|
|
snprintf(ipBuf, sizeof(ipBuf), "IP: %s", WiFi.localIP().toString().c_str());
|
|
nodeConfigLabels.emplace_back(ipBuf);
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_NETWORK));
|
|
}
|
|
|
|
// SSID
|
|
if (config.network.wifi_ssid && strlen(config.network.wifi_ssid) > 0) {
|
|
std::string ssidLabel = "SSID: ";
|
|
ssidLabel += config.network.wifi_ssid;
|
|
nodeConfigLabels.emplace_back(ssidLabel);
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_NETWORK));
|
|
}
|
|
|
|
// Hostname
|
|
const char *host = WiFi.getHostname();
|
|
if (host && strlen(host) > 0) {
|
|
std::string hostLabel = "Host: ";
|
|
hostLabel += host;
|
|
nodeConfigLabels.emplace_back(hostLabel);
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_NETWORK));
|
|
}
|
|
}
|
|
#endif
|
|
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
}
|
|
|
|
case NODE_CONFIG_DISPLAY: {
|
|
previousPage = MenuPage::NODE_CONFIG;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
|
|
items.push_back(MenuItem("12-Hour Clock", MenuAction::TOGGLE_12H_CLOCK, MenuPage::NODE_CONFIG_DISPLAY,
|
|
&config.display.use_12h_clock));
|
|
|
|
nodeConfigLabels.emplace_back("Screen Timeout: " + std::string(getDisplayTimeoutLabel(config.display.screen_on_secs)));
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_DISPLAY_TIMEOUT));
|
|
|
|
const char *unitsLabel =
|
|
(config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) ? "Units: Imperial" : "Units: Metric";
|
|
|
|
items.push_back(MenuItem(unitsLabel, MenuAction::TOGGLE_DISPLAY_UNITS, MenuPage::NODE_CONFIG_DISPLAY));
|
|
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
}
|
|
|
|
case NODE_CONFIG_DISPLAY_TIMEOUT:
|
|
previousPage = MenuPage::NODE_CONFIG_DISPLAY;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
populateDisplayTimeoutPage();
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
|
|
case NODE_CONFIG_BLUETOOTH: {
|
|
previousPage = MenuPage::NODE_CONFIG;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
|
|
const char *btLabel = config.bluetooth.enabled ? "Bluetooth: On" : "Bluetooth: Off";
|
|
items.push_back(MenuItem(btLabel, MenuAction::TOGGLE_BLUETOOTH, MenuPage::EXIT));
|
|
|
|
const char *pairLabel = config.bluetooth.fixed_pin ? "Pair Mode: Fixed" : "Pair Mode: Random";
|
|
items.push_back(MenuItem(pairLabel, MenuAction::TOGGLE_BLUETOOTH_PAIR_MODE, MenuPage::NODE_CONFIG_BLUETOOTH));
|
|
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
}
|
|
|
|
case NODE_CONFIG_LORA: {
|
|
previousPage = MenuPage::NODE_CONFIG;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
|
|
const char *region = myRegion ? myRegion->name : "Unset";
|
|
nodeConfigLabels.emplace_back("Region: " + std::string(region));
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::REGION));
|
|
|
|
const char *preset =
|
|
DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset);
|
|
nodeConfigLabels.emplace_back("Preset: " + std::string(preset));
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_PRESET));
|
|
|
|
char freqBuf[32];
|
|
float freq = RadioLibInterface::instance->getFreq();
|
|
snprintf(freqBuf, sizeof(freqBuf), "Freq: %.3f MHz", freq);
|
|
nodeConfigLabels.emplace_back(freqBuf);
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_LORA));
|
|
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
}
|
|
|
|
case NODE_CONFIG_CHANNELS: {
|
|
previousPage = MenuPage::NODE_CONFIG;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
|
|
for (uint8_t i = 0; i < MAX_NUM_CHANNELS; i++) {
|
|
const meshtastic_Channel &ch = channels.getByIndex(i);
|
|
|
|
if (!ch.has_settings)
|
|
continue;
|
|
|
|
if (ch.role == meshtastic_Channel_Role_DISABLED)
|
|
continue;
|
|
|
|
std::string label = "#";
|
|
|
|
if (ch.role == meshtastic_Channel_Role_PRIMARY) {
|
|
label += "Primary";
|
|
} else if (strlen(ch.settings.name) > 0) {
|
|
label += parse(ch.settings.name);
|
|
} else {
|
|
label += "Channel" + to_string(i + 1);
|
|
}
|
|
|
|
nodeConfigLabels.push_back(label);
|
|
items.push_back(
|
|
MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_CHANNEL_DETAIL));
|
|
}
|
|
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
}
|
|
|
|
case NODE_CONFIG_CHANNEL_DETAIL: {
|
|
previousPage = MenuPage::NODE_CONFIG_CHANNELS;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
|
|
meshtastic_Channel &ch = channels.getByIndex(selectedChannelIndex);
|
|
|
|
// Name (read-only)
|
|
const char *name = strlen(ch.settings.name) > 0 ? ch.settings.name : "Unnamed";
|
|
nodeConfigLabels.emplace_back("Ch: " + parse(name));
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_CHANNEL_DETAIL));
|
|
|
|
// Uplink
|
|
items.push_back(MenuItem("Uplink", MenuAction::TOGGLE_CHANNEL_UPLINK, MenuPage::NODE_CONFIG_CHANNEL_DETAIL,
|
|
&ch.settings.uplink_enabled));
|
|
|
|
items.push_back(MenuItem("Downlink", MenuAction::TOGGLE_CHANNEL_DOWNLINK, MenuPage::NODE_CONFIG_CHANNEL_DETAIL,
|
|
&ch.settings.downlink_enabled));
|
|
|
|
// Position
|
|
channelPositionEnabled = ch.settings.has_module_settings && ch.settings.module_settings.position_precision > 0;
|
|
|
|
items.push_back(MenuItem("Position", MenuAction::TOGGLE_CHANNEL_POSITION, MenuPage::NODE_CONFIG_CHANNEL_DETAIL,
|
|
&channelPositionEnabled));
|
|
|
|
// Precision
|
|
if (channelPositionEnabled) {
|
|
|
|
std::string precisionLabel = "Unknown";
|
|
|
|
for (const auto &opt : POSITION_PRECISION_OPTIONS) {
|
|
if (opt.value == ch.settings.module_settings.position_precision) {
|
|
precisionLabel = (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL)
|
|
? opt.imperial
|
|
: opt.metric;
|
|
break;
|
|
}
|
|
}
|
|
nodeConfigLabels.emplace_back("Precision: " + precisionLabel);
|
|
items.push_back(
|
|
MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_CHANNEL_PRECISION));
|
|
}
|
|
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
}
|
|
|
|
case NODE_CONFIG_CHANNEL_PRECISION: {
|
|
previousPage = MenuPage::NODE_CONFIG_CHANNEL_DETAIL;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
const meshtastic_Channel &ch = channels.getByIndex(selectedChannelIndex);
|
|
if (!ch.settings.has_module_settings || ch.settings.module_settings.position_precision == 0) {
|
|
items.push_back(MenuItem("Position is Off", MenuPage::NODE_CONFIG_CHANNEL_DETAIL));
|
|
break;
|
|
}
|
|
constexpr uint8_t optionCount = sizeof(POSITION_PRECISION_OPTIONS) / sizeof(POSITION_PRECISION_OPTIONS[0]);
|
|
for (uint8_t i = 0; i < optionCount; i++) {
|
|
const auto &opt = POSITION_PRECISION_OPTIONS[i];
|
|
const char *label =
|
|
(config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) ? opt.imperial : opt.metric;
|
|
nodeConfigLabels.emplace_back(label);
|
|
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_CHANNEL_PRECISION,
|
|
MenuPage::NODE_CONFIG_CHANNEL_DETAIL));
|
|
}
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
}
|
|
|
|
case NODE_CONFIG_DEVICE_ROLE: {
|
|
previousPage = MenuPage::NODE_CONFIG_DEVICE;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
items.push_back(MenuItem("Client", MenuAction::SET_ROLE_CLIENT, MenuPage::EXIT));
|
|
items.push_back(MenuItem("Client Mute", MenuAction::SET_ROLE_CLIENT_MUTE, MenuPage::EXIT));
|
|
items.push_back(MenuItem("Router", MenuAction::SET_ROLE_ROUTER, MenuPage::EXIT));
|
|
items.push_back(MenuItem("Repeater", MenuAction::SET_ROLE_REPEATER, MenuPage::EXIT));
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
}
|
|
|
|
case TIMEZONE:
|
|
previousPage = MenuPage::NODE_CONFIG_DEVICE;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
items.push_back(MenuItem("US/Hawaii", SET_TZ_US_HAWAII, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("US/Alaska", SET_TZ_US_ALASKA, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("US/Pacific", SET_TZ_US_PACIFIC, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("US/Arizona", SET_TZ_US_ARIZONA, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("US/Mountain", SET_TZ_US_MOUNTAIN, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("US/Central", SET_TZ_US_CENTRAL, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("US/Eastern", SET_TZ_US_EASTERN, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("BR/Brasilia", SET_TZ_BR_BRAZILIA, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("UTC", SET_TZ_UTC, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("EU/Western", SET_TZ_EU_WESTERN, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("EU/Central", SET_TZ_EU_CENTRAL, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("EU/Eastern", SET_TZ_EU_EASTERN, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("Asia/Kolkata", SET_TZ_ASIA_KOLKATA, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("Asia/Hong Kong", SET_TZ_ASIA_HONG_KONG, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("AU/AWST", SET_TZ_AU_AWST, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("AU/ACST", SET_TZ_AU_ACST, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("AU/AEST", SET_TZ_AU_AEST, MenuPage::NODE_CONFIG_DEVICE));
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
|
|
case REGION:
|
|
previousPage = MenuPage::NODE_CONFIG_LORA;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
items.push_back(MenuItem("US", MenuAction::SET_REGION_US, MenuPage::EXIT));
|
|
items.push_back(MenuItem("EU 868", MenuAction::SET_REGION_EU_868, MenuPage::EXIT));
|
|
items.push_back(MenuItem("EU 433", MenuAction::SET_REGION_EU_433, MenuPage::EXIT));
|
|
items.push_back(MenuItem("EU 866", MenuAction::SET_REGION_EU_866, MenuPage::EXIT));
|
|
items.push_back(MenuItem("EU 868 Narrow", MenuAction::SET_REGION_NARROW_868, MenuPage::EXIT));
|
|
items.push_back(MenuItem("CN", MenuAction::SET_REGION_CN, MenuPage::EXIT));
|
|
items.push_back(MenuItem("JP", MenuAction::SET_REGION_JP, MenuPage::EXIT));
|
|
items.push_back(MenuItem("ANZ", MenuAction::SET_REGION_ANZ, MenuPage::EXIT));
|
|
items.push_back(MenuItem("KR", MenuAction::SET_REGION_KR, MenuPage::EXIT));
|
|
items.push_back(MenuItem("TW", MenuAction::SET_REGION_TW, MenuPage::EXIT));
|
|
items.push_back(MenuItem("RU", MenuAction::SET_REGION_RU, MenuPage::EXIT));
|
|
items.push_back(MenuItem("IN", MenuAction::SET_REGION_IN, MenuPage::EXIT));
|
|
items.push_back(MenuItem("NZ 865", MenuAction::SET_REGION_NZ_865, MenuPage::EXIT));
|
|
items.push_back(MenuItem("TH", MenuAction::SET_REGION_TH, MenuPage::EXIT));
|
|
items.push_back(MenuItem("LoRa 2.4", MenuAction::SET_REGION_LORA_24, MenuPage::EXIT));
|
|
items.push_back(MenuItem("UA 433", MenuAction::SET_REGION_UA_433, MenuPage::EXIT));
|
|
items.push_back(MenuItem("MY 433", MenuAction::SET_REGION_MY_433, MenuPage::EXIT));
|
|
items.push_back(MenuItem("MY 919", MenuAction::SET_REGION_MY_919, MenuPage::EXIT));
|
|
items.push_back(MenuItem("SG 923", MenuAction::SET_REGION_SG_923, MenuPage::EXIT));
|
|
items.push_back(MenuItem("PH 433", MenuAction::SET_REGION_PH_433, MenuPage::EXIT));
|
|
items.push_back(MenuItem("PH 868", MenuAction::SET_REGION_PH_868, MenuPage::EXIT));
|
|
items.push_back(MenuItem("PH 915", MenuAction::SET_REGION_PH_915, MenuPage::EXIT));
|
|
items.push_back(MenuItem("ANZ 433", MenuAction::SET_REGION_ANZ_433, MenuPage::EXIT));
|
|
items.push_back(MenuItem("KZ 433", MenuAction::SET_REGION_KZ_433, MenuPage::EXIT));
|
|
items.push_back(MenuItem("KZ 863", MenuAction::SET_REGION_KZ_863, MenuPage::EXIT));
|
|
items.push_back(MenuItem("NP 865", MenuAction::SET_REGION_NP_865, MenuPage::EXIT));
|
|
items.push_back(MenuItem("BR 902", MenuAction::SET_REGION_BR_902, MenuPage::EXIT));
|
|
items.push_back(MenuItem("ITU1_2M (144-146)", MenuAction::SET_REGION_ITU1_2M, MenuPage::EXIT));
|
|
items.push_back(MenuItem("ITU2_2M (144-148)", MenuAction::SET_REGION_ITU2_2M, MenuPage::EXIT));
|
|
items.push_back(MenuItem("ITU3_2M (144-148)", MenuAction::SET_REGION_ITU3_2M, MenuPage::EXIT));
|
|
items.push_back(MenuItem("ITU2_125CM (220-225)", MenuAction::SET_REGION_ITU2_125CM, MenuPage::EXIT));
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
|
|
case NODE_CONFIG_PRESET: {
|
|
previousPage = MenuPage::NODE_CONFIG_LORA;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
regionPresetCount = 0;
|
|
if (myRegion && myRegion->profile) {
|
|
const meshtastic_Config_LoRaConfig_ModemPreset *presets = myRegion->getAvailablePresets();
|
|
size_t numPresets = myRegion->getNumPresets();
|
|
for (size_t i = 0; i < numPresets && regionPresetCount < MAX_REGION_PRESETS; ++i) {
|
|
regionPresets[regionPresetCount++] = presets[i];
|
|
const char *name = DisplayFormatters::getModemPresetDisplayName(presets[i], false, true);
|
|
nodeConfigLabels.emplace_back(name);
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_PRESET_FROM_REGION, MenuPage::EXIT));
|
|
}
|
|
}
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
}
|
|
// Administration Section
|
|
case NODE_CONFIG_ADMIN_RESET:
|
|
previousPage = MenuPage::NODE_CONFIG;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
items.push_back(MenuItem("Reset All", MenuAction::RESET_NODEDB_ALL, MenuPage::EXIT));
|
|
items.push_back(MenuItem("Keep Favorites Only", MenuAction::RESET_NODEDB_KEEP_FAVORITES, MenuPage::EXIT));
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
|
|
case NODE_CONFIG_ADMIN_MESSAGES:
|
|
previousPage = MenuPage::NODE_CONFIG;
|
|
items.push_back(MenuItem("Back", previousPage));
|
|
items.push_back(MenuItem("Wipe All Messages", MenuAction::WIPE_MESSAGES_ALL, MenuPage::EXIT));
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
break;
|
|
|
|
// Exit
|
|
case EXIT:
|
|
sendToBackground(); // Menu applet dismissed, allow normal behavior to resume
|
|
break;
|
|
|
|
default:
|
|
LOG_WARN("Page not implemented");
|
|
}
|
|
|
|
// Reset the cursor, unless reloading same page
|
|
// (or now out-of-bounds)
|
|
if (page != currentPage || cursor >= items.size()) {
|
|
cursor = 0;
|
|
|
|
// ROOT menu has special handling: unselected at first, to emphasise the system info panel
|
|
if (page == ROOT)
|
|
cursorShown = false;
|
|
}
|
|
|
|
// Ensure cursor never rests on a header
|
|
if (cursorShown) {
|
|
while (cursor < items.size() && items.at(cursor).isHeader) {
|
|
cursor++;
|
|
}
|
|
if (cursor >= items.size())
|
|
cursor = 0;
|
|
}
|
|
|
|
// Remember which page we are on now
|
|
currentPage = page;
|
|
}
|
|
|
|
void InkHUD::MenuApplet::onRender(bool full)
|
|
{
|
|
// Free text mode draws a text input field and skips the normal rendering
|
|
if (freeTextMode) {
|
|
drawInputField(0, fontSmall.lineHeight(), X(1.0), Y(1.0) - fontSmall.lineHeight() - 1, cm.freeTextItem.rawText);
|
|
return;
|
|
}
|
|
|
|
if (items.size() == 0)
|
|
LOG_ERROR("Empty Menu");
|
|
|
|
const bool touchFriendlyLayout = useTouchFriendlyMenuLayout(inkhud);
|
|
AppletFont menuItemFont = touchFriendlyLayout ? fontMedium : fontSmall;
|
|
setFont(menuItemFont);
|
|
|
|
// Dimensions for the slots where we will draw menuItems
|
|
const float padding = 0.05;
|
|
const uint16_t itemH = getMenuItemHeightPx(inkhud);
|
|
const int16_t selectInsetY = touchFriendlyLayout ? 3 : 2;
|
|
const int16_t itemW = width() - X(padding) - X(padding);
|
|
const int16_t itemL = X(padding);
|
|
const int16_t itemR = X(1 - padding);
|
|
int16_t itemT = 0; // Top (y px of current slot). Incremented as we draw. Adjusted to fit system info panel on ROOT menu.
|
|
|
|
// How many full menuItems will fit on screen
|
|
uint8_t slotCount = (height() - itemT) / itemH;
|
|
|
|
// System info panel at the top of the menu
|
|
// =========================================
|
|
|
|
uint16_t &siH = systemInfoPanelHeight; // System info - height. Calculated at onForeground
|
|
const uint8_t slotsObscured = ceilf(siH / (float)itemH); // How many slots are obscured by system info panel
|
|
|
|
// System info - top
|
|
// Remain at 0px, until cursor reaches bottom of screen, then begin to scroll off screen.
|
|
// This is the same behavior we expect from the non-root menus.
|
|
// Implementing this with the systemp panel is slightly annoying though,
|
|
// and required adding the MenuApplet::getSystemInfoPanelHeight method
|
|
int16_t siT;
|
|
if (cursor < slotCount - slotsObscured - 1) // (Minus 1: comparing zero based index with a count)
|
|
siT = 0;
|
|
else
|
|
siT = 0 - ((cursor - (slotCount - slotsObscured - 1)) * itemH);
|
|
|
|
// If showing ROOT menu,
|
|
// and the panel isn't yet scrolled off screen top
|
|
if (currentPage == ROOT) {
|
|
drawSystemInfoPanel(0, siT, width()); // Draw the panel.
|
|
itemT = max(siT + siH, 0); // Offset the first menu entry, so menu starts below the system info panel
|
|
}
|
|
|
|
// drawSystemInfoPanel() changes font state (clock/info text).
|
|
// Restore the active menu font so ROOT page item text matches other menu pages,
|
|
// including touch-friendly layouts.
|
|
setFont(menuItemFont);
|
|
|
|
// Draw menu items
|
|
// ===================
|
|
|
|
// Which item will be drawn to the top-most slot?
|
|
// Initially, this is the item 0, but may increase once we begin scrolling
|
|
uint8_t firstItem;
|
|
if (cursor < slotCount)
|
|
firstItem = 0;
|
|
else
|
|
firstItem = cursor - (slotCount - 1);
|
|
|
|
// Which item will be drawn to the bottom-most slot?
|
|
// This may be beyond the slot-count, to draw a partially off-screen item below the bottom-most slow
|
|
// This may be less than the slot-count, if we are reaching the end of the menuItems
|
|
uint8_t lastItem = min((uint8_t)firstItem + slotCount, (uint8_t)items.size() - 1);
|
|
|
|
// -- Loop: draw each (visible) menu item --
|
|
for (uint8_t i = firstItem; i <= lastItem; i++) {
|
|
|
|
// Grab the menu item
|
|
MenuItem &item = items.at(i);
|
|
|
|
// Vertical center of this slot
|
|
int16_t center = itemT + (itemH / 2);
|
|
|
|
// Header (non-selectable section label)
|
|
if (item.isHeader) {
|
|
// Header text (flush left)
|
|
printAt(itemL + X(padding), center, item.label, LEFT, MIDDLE);
|
|
|
|
// Subtle underline
|
|
int16_t underlineY = itemT + itemH - 2;
|
|
drawLine(itemL + X(padding), underlineY, itemR - X(padding), underlineY, BLACK);
|
|
} else {
|
|
// Box, if currently selected
|
|
if (cursorShown && i == cursor && (!touchFriendlyLayout || !hideTouchSelectionHighlight)) {
|
|
const int16_t selTop = itemT + selectInsetY;
|
|
const int16_t selH = itemH - (selectInsetY * 2);
|
|
drawRect(itemL, selTop, itemW, selH, BLACK);
|
|
// Touch layouts need a stronger visual cue than a thin outline.
|
|
if (touchFriendlyLayout) {
|
|
const int16_t markerInset = 3;
|
|
const int16_t markerW = 4;
|
|
fillRect(itemL + markerInset, selTop + markerInset, markerW, max(1, selH - (markerInset * 2)), BLACK);
|
|
}
|
|
}
|
|
|
|
// Indented normal item text
|
|
printAt(itemL + X(padding * 2), center, item.label, LEFT, MIDDLE);
|
|
}
|
|
|
|
// Checkbox, if relevant
|
|
if (item.checkState) {
|
|
const uint16_t cbWH = menuItemFont.lineHeight(); // Checkbox: width / height
|
|
const int16_t cbL = itemR - X(padding) - cbWH; // Checkbox: left
|
|
const int16_t cbT = center - (cbWH / 2); // Checkbox : top
|
|
// Checkbox ticked
|
|
if (*(item.checkState)) {
|
|
drawRect(cbL, cbT, cbWH, cbWH, BLACK);
|
|
// First point of tick: pen down
|
|
const int16_t t1Y = center;
|
|
const int16_t t1X = cbL + 3;
|
|
// Second point of tick: base
|
|
const int16_t t2Y = center + (cbWH / 2) - 2;
|
|
const int16_t t2X = cbL + (cbWH / 2);
|
|
// Third point of tick: end of tail
|
|
const int16_t t3Y = center - (cbWH / 2) - 2;
|
|
const int16_t t3X = cbL + cbWH + 2;
|
|
// Draw twice: faux bold
|
|
drawLine(t1X, t1Y, t2X, t2Y, BLACK);
|
|
drawLine(t2X, t2Y, t3X, t3Y, BLACK);
|
|
drawLine(t1X + 1, t1Y, t2X + 1, t2Y, BLACK);
|
|
drawLine(t2X + 1, t2Y, t3X + 1, t3Y, BLACK);
|
|
}
|
|
// Checkbox ticked
|
|
else
|
|
drawRect(cbL, cbT, cbWH, cbWH, BLACK);
|
|
}
|
|
|
|
// Increment the y value (top) as we go
|
|
itemT += itemH;
|
|
}
|
|
}
|
|
|
|
bool InkHUD::MenuApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress)
|
|
{
|
|
(void)longPress;
|
|
|
|
if (freeTextMode || !getTile()) {
|
|
return false;
|
|
}
|
|
|
|
const uint16_t tileL = getTile()->getLeft();
|
|
const uint16_t tileT = getTile()->getTop();
|
|
const uint16_t tileR = tileL + getTile()->getWidth();
|
|
const uint16_t tileB = tileT + getTile()->getHeight();
|
|
if (x < tileL || x >= tileR || y < tileT || y >= tileB) {
|
|
return false;
|
|
}
|
|
|
|
if (items.empty()) {
|
|
return true;
|
|
}
|
|
|
|
// Direct touch controls should act as activity and keep the menu open.
|
|
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
|
|
|
|
// If button-driven selection is active on touch-first layouts, clear it as soon as
|
|
// touch interaction resumes so touch behavior remains direct/tap-first.
|
|
if (useTouchFriendlyMenuLayout(inkhud)) {
|
|
cursorShown = false;
|
|
hideTouchSelectionHighlight = true;
|
|
}
|
|
|
|
const int16_t localY = (int16_t)y - (int16_t)tileT;
|
|
|
|
// Keep geometry in sync with onRender() so touch hit-testing matches what users see.
|
|
const uint16_t itemH = getMenuItemHeightPx(inkhud);
|
|
int16_t itemT = 0;
|
|
uint8_t slotCount = (height() - itemT) / itemH;
|
|
if (slotCount == 0) {
|
|
slotCount = 1;
|
|
}
|
|
const uint16_t &siH = systemInfoPanelHeight;
|
|
const uint8_t slotsObscured = ceilf(siH / (float)itemH);
|
|
|
|
if (currentPage == ROOT) {
|
|
int16_t siT = 0;
|
|
const int16_t scrollThreshold = (int16_t)slotCount - (int16_t)slotsObscured - 1;
|
|
if (scrollThreshold >= 0 && (int16_t)cursor >= scrollThreshold) {
|
|
siT = 0 - ((cursor - scrollThreshold) * itemH);
|
|
}
|
|
itemT = max((int16_t)(siT + siH), (int16_t)0);
|
|
}
|
|
|
|
const uint8_t firstItem = (cursor < slotCount) ? 0 : (cursor - (slotCount - 1));
|
|
uint16_t visibleEnd = (uint16_t)firstItem + (uint16_t)slotCount;
|
|
const uint8_t maxIndex = (uint8_t)items.size() - 1;
|
|
if (visibleEnd > maxIndex) {
|
|
visibleEnd = maxIndex;
|
|
}
|
|
const uint8_t lastItem = (uint8_t)visibleEnd;
|
|
|
|
for (uint8_t i = firstItem; i <= lastItem; i++) {
|
|
const int16_t rowTop = itemT;
|
|
const int16_t rowBottom = itemT + itemH;
|
|
|
|
if (localY >= rowTop && localY < rowBottom) {
|
|
if (items.at(i).isHeader) {
|
|
// Consume taps on headers so they don't fall back to button semantics.
|
|
return true;
|
|
}
|
|
|
|
cursor = i;
|
|
cursorShown = true;
|
|
execute(items.at(cursor));
|
|
|
|
if (!wantsToRender()) {
|
|
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
itemT += itemH;
|
|
}
|
|
|
|
// Consume taps on menu whitespace so we don't trigger button-like fallback behavior.
|
|
return true;
|
|
}
|
|
|
|
void InkHUD::MenuApplet::onButtonShortPress()
|
|
{
|
|
if (!freeTextMode) {
|
|
// Push the auto-close timer back
|
|
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
|
|
|
|
// Touch-first nodes keep user-button short-press as "advance selection" in menus.
|
|
// Any button-driven navigation should restore visible highlight.
|
|
hideTouchSelectionHighlight = false;
|
|
if (!settings->joystick.enabled || useTouchFriendlyMenuLayout(inkhud)) {
|
|
if (!cursorShown) {
|
|
cursorShown = true;
|
|
// Select the first item that isn't a header
|
|
cursor = 0;
|
|
while (cursor < items.size() && items.at(cursor).isHeader) {
|
|
cursor++;
|
|
}
|
|
if (cursor >= items.size()) {
|
|
cursorShown = false;
|
|
cursor = 0;
|
|
}
|
|
} else {
|
|
do {
|
|
cursor = (cursor + 1) % items.size();
|
|
} while (items.at(cursor).isHeader);
|
|
}
|
|
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
|
} else {
|
|
if (cursorShown)
|
|
execute(items.at(cursor));
|
|
else
|
|
showPage(MenuPage::EXIT);
|
|
if (!wantsToRender())
|
|
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
|
}
|
|
}
|
|
}
|
|
|
|
void InkHUD::MenuApplet::onButtonLongPress()
|
|
{
|
|
if (!freeTextMode) {
|
|
// Push the auto-close timer back
|
|
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
|
|
|
|
if (cursorShown)
|
|
execute(items.at(cursor));
|
|
else
|
|
showPage(MenuPage::EXIT); // Special case: Peek at root-menu; longpress again to close
|
|
|
|
// If we didn't already request a specialized update, when handling a menu action,
|
|
// then perform the usual fast update.
|
|
// FAST keeps things responsive: important because we're dealing with user input
|
|
if (!wantsToRender())
|
|
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
|
}
|
|
}
|
|
|
|
void InkHUD::MenuApplet::onExitShort()
|
|
{
|
|
// Exit the menu
|
|
showPage(MenuPage::EXIT);
|
|
|
|
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
|
}
|
|
|
|
void InkHUD::MenuApplet::onNavUp()
|
|
{
|
|
if (!freeTextMode) {
|
|
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
|
|
|
|
// Touch-first menus: swipe up/down should scroll only.
|
|
// Keep cursor movement for scroll math, but selection box is hidden in onRender().
|
|
if (useTouchFriendlyMenuLayout(inkhud)) {
|
|
hideTouchSelectionHighlight = true;
|
|
if (!cursorShown) {
|
|
cursorShown = true;
|
|
cursor = items.size() - 1;
|
|
while (items.at(cursor).isHeader) {
|
|
if (cursor == 0) {
|
|
cursorShown = false;
|
|
break;
|
|
}
|
|
cursor--;
|
|
}
|
|
} else {
|
|
do {
|
|
if (cursor == 0)
|
|
cursor = items.size() - 1;
|
|
else
|
|
cursor--;
|
|
} while (items.at(cursor).isHeader);
|
|
}
|
|
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
|
return;
|
|
}
|
|
|
|
if (!cursorShown) {
|
|
cursorShown = true;
|
|
// Select the last item that isn't a header
|
|
cursor = items.size() - 1;
|
|
while (items.at(cursor).isHeader) {
|
|
if (cursor == 0) {
|
|
cursorShown = false;
|
|
break;
|
|
}
|
|
cursor--;
|
|
}
|
|
} else {
|
|
do {
|
|
if (cursor == 0)
|
|
cursor = items.size() - 1;
|
|
else
|
|
cursor--;
|
|
} while (items.at(cursor).isHeader);
|
|
}
|
|
|
|
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
|
}
|
|
}
|
|
|
|
void InkHUD::MenuApplet::onNavDown()
|
|
{
|
|
if (!freeTextMode) {
|
|
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
|
|
|
|
// Touch-first menus: swipe up/down should scroll only.
|
|
// Keep cursor movement for scroll math, but selection box is hidden in onRender().
|
|
if (useTouchFriendlyMenuLayout(inkhud)) {
|
|
hideTouchSelectionHighlight = true;
|
|
if (!cursorShown) {
|
|
cursorShown = true;
|
|
cursor = 0;
|
|
while (cursor < items.size() && items.at(cursor).isHeader) {
|
|
cursor++;
|
|
}
|
|
if (cursor >= items.size()) {
|
|
cursorShown = false;
|
|
cursor = 0;
|
|
}
|
|
} else {
|
|
do {
|
|
cursor = (cursor + 1) % items.size();
|
|
} while (items.at(cursor).isHeader);
|
|
}
|
|
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
|
return;
|
|
}
|
|
|
|
if (!cursorShown) {
|
|
cursorShown = true;
|
|
// Select the first item that isn't a header
|
|
cursor = 0;
|
|
while (cursor < items.size() && items.at(cursor).isHeader) {
|
|
cursor++;
|
|
}
|
|
if (cursor >= items.size()) {
|
|
cursorShown = false;
|
|
cursor = 0;
|
|
}
|
|
} else {
|
|
do {
|
|
cursor = (cursor + 1) % items.size();
|
|
} while (items.at(cursor).isHeader);
|
|
}
|
|
|
|
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
|
}
|
|
}
|
|
|
|
void InkHUD::MenuApplet::onNavLeft()
|
|
{
|
|
if (!freeTextMode) {
|
|
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
|
|
|
|
// Go to the previous menu page
|
|
showPage(previousPage);
|
|
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
|
}
|
|
}
|
|
|
|
void InkHUD::MenuApplet::onNavRight()
|
|
{
|
|
if (!freeTextMode) {
|
|
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
|
|
if (cursorShown)
|
|
execute(items.at(cursor));
|
|
if (!wantsToRender())
|
|
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
|
}
|
|
}
|
|
|
|
void InkHUD::MenuApplet::onFreeText(char c)
|
|
{
|
|
if (cm.freeTextItem.rawText.length() >= menuTextLimit && c != '\b')
|
|
return;
|
|
if (c == '\b') {
|
|
if (!cm.freeTextItem.rawText.empty())
|
|
cm.freeTextItem.rawText.pop_back();
|
|
} else {
|
|
cm.freeTextItem.rawText += c;
|
|
}
|
|
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
|
}
|
|
|
|
void InkHUD::MenuApplet::onFreeTextDone()
|
|
{
|
|
// Restart the auto-close timeout
|
|
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
|
|
OSThread::enabled = true;
|
|
|
|
handleFreeText = false;
|
|
freeTextMode = false;
|
|
|
|
if (!cm.freeTextItem.rawText.empty()) {
|
|
cm.selectedMessageItem = &cm.freeTextItem;
|
|
showPage(MenuPage::CANNEDMESSAGE_RECIPIENT);
|
|
}
|
|
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
|
}
|
|
|
|
void InkHUD::MenuApplet::onFreeTextCancel()
|
|
{
|
|
// Restart the auto-close timeout
|
|
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
|
|
OSThread::enabled = true;
|
|
|
|
handleFreeText = false;
|
|
freeTextMode = false;
|
|
|
|
// Clear the free text message
|
|
cm.freeTextItem.rawText.erase();
|
|
|
|
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
|
|
}
|
|
|
|
// Dynamically create MenuItem entries for activating / deactivating Applets, for the "Applet Selection" submenu
|
|
void InkHUD::MenuApplet::populateAppletPage()
|
|
{
|
|
assert(items.size() == 0);
|
|
|
|
for (uint8_t i = 0; i < inkhud->userApplets.size(); i++) {
|
|
const char *name = inkhud->userApplets.at(i)->name;
|
|
bool *isActive = &(settings->userApplets.active[i]);
|
|
items.push_back(MenuItem(name, MenuAction::TOGGLE_APPLET, MenuPage::APPLETS, isActive));
|
|
}
|
|
}
|
|
|
|
// Dynamically create MenuItem entries for selecting which applets will automatically come to foreground when they have new data
|
|
// We only populate this menu page with applets which are actually active
|
|
// We use the MenuItem::checkState pointer to toggle the setting in MenuApplet::execute. Bit of a hack, but convenient.
|
|
void InkHUD::MenuApplet::populateAutoshowPage()
|
|
{
|
|
assert(items.size() == 0);
|
|
|
|
for (uint8_t i = 0; i < inkhud->userApplets.size(); i++) {
|
|
// Only add a menu item if applet is active
|
|
if (settings->userApplets.active[i]) {
|
|
const char *name = inkhud->userApplets.at(i)->name;
|
|
bool *isActive = &(settings->userApplets.autoshow[i]);
|
|
items.push_back(MenuItem(name, MenuAction::TOGGLE_AUTOSHOW_APPLET, MenuPage::AUTOSHOW, isActive));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create MenuItem entries to select our definition of "Recent"
|
|
// Controls how long data will remain in any "Recents" flavored applets
|
|
void InkHUD::MenuApplet::populateRecentsPage()
|
|
{
|
|
// How many values are shown for use to choose from
|
|
constexpr uint8_t optionCount = sizeof(RECENTS_OPTIONS_MINUTES) / sizeof(RECENTS_OPTIONS_MINUTES[0]);
|
|
|
|
// Create an entry for each item in RECENTS_OPTIONS_MINUTES array
|
|
// (Defined at top of this file)
|
|
for (uint8_t i = 0; i < optionCount; i++) {
|
|
std::string label = to_string(RECENTS_OPTIONS_MINUTES[i]) + " mins";
|
|
recentsSelected[i] = (settings->recentlyActiveSeconds == RECENTS_OPTIONS_MINUTES[i] * 60);
|
|
items.push_back(MenuItem(label.c_str(), MenuAction::SET_RECENTS, MenuPage::OPTIONS, &recentsSelected[i]));
|
|
}
|
|
}
|
|
|
|
void InkHUD::MenuApplet::populateDisplayTimeoutPage()
|
|
{
|
|
constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]);
|
|
for (uint8_t i = 0; i < optionCount; i++) {
|
|
displayTimeoutSelected[i] = (config.display.screen_on_secs == DISPLAY_TIMEOUT_OPTIONS[i].seconds);
|
|
nodeConfigLabels.emplace_back(DISPLAY_TIMEOUT_OPTIONS[i].label);
|
|
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_DISPLAY_TIMEOUT, MenuPage::NODE_CONFIG_DISPLAY,
|
|
&displayTimeoutSelected[i]));
|
|
}
|
|
}
|
|
|
|
// MenuItem entries for the "send" page
|
|
// Dynamically creates menu items based on available canned messages
|
|
void InkHUD::MenuApplet::populateSendPage()
|
|
{
|
|
// Position / NodeInfo packet
|
|
items.push_back(MenuItem("Ping", MenuAction::SEND_PING, MenuPage::EXIT));
|
|
|
|
// Show the Free Text option on any node that supports the on-screen keyboard.
|
|
if (supportsFreeTextKeyboard(inkhud, settings))
|
|
items.push_back(MenuItem("Free Text", MenuAction::FREE_TEXT, MenuPage::SEND));
|
|
|
|
// One menu item for each canned message
|
|
uint8_t count = cm.store->size();
|
|
for (uint8_t i = 0; i < count; i++) {
|
|
// Gather the information for this item
|
|
CannedMessages::MessageItem messageItem;
|
|
messageItem.rawText = cm.store->at(i);
|
|
messageItem.label = parse(messageItem.rawText);
|
|
|
|
// Store the item (until the menu closes)
|
|
cm.messageItems.push_back(messageItem);
|
|
|
|
// Create a menu item
|
|
const char *itemText = cm.messageItems.back().label.c_str();
|
|
items.push_back(MenuItem(itemText, MenuAction::STORE_CANNEDMESSAGE_SELECTION, MenuPage::CANNEDMESSAGE_RECIPIENT));
|
|
}
|
|
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
}
|
|
|
|
// Dynamically create MenuItem entries for possible canned message destinations
|
|
// All available channels are shown
|
|
// Favorite nodes are shown, provided we don't have an *excessive* amount
|
|
void InkHUD::MenuApplet::populateRecipientPage()
|
|
{
|
|
// Create recipient data (and menu items) for any channels
|
|
// --------------------------------------------------------
|
|
|
|
for (uint8_t i = 0; i < MAX_NUM_CHANNELS; i++) {
|
|
// Get the channel, and check if it's enabled
|
|
const meshtastic_Channel &channel = channels.getByIndex(i);
|
|
if (!channel.has_settings || channel.role == meshtastic_Channel_Role_DISABLED)
|
|
continue;
|
|
|
|
CannedMessages::RecipientItem r;
|
|
|
|
// Set index
|
|
r.channelIndex = channel.index;
|
|
|
|
// Set a label for the menu item
|
|
r.label = "Ch " + to_string(i) + ": ";
|
|
if (channel.role == meshtastic_Channel_Role_PRIMARY)
|
|
r.label += "Primary";
|
|
else
|
|
r.label += parse(channel.settings.name);
|
|
|
|
// Add to the list of recipients
|
|
cm.recipientItems.push_back(r);
|
|
|
|
// Add a menu item for this recipient
|
|
const char *itemText = cm.recipientItems.back().label.c_str();
|
|
items.push_back(MenuItem(itemText, SEND_CANNEDMESSAGE, MenuPage::EXIT));
|
|
}
|
|
|
|
// Create recipient data (and menu items) for favorite nodes
|
|
// ---------------------------------------------------------
|
|
|
|
uint32_t nodeCount = nodeDB->getNumMeshNodes();
|
|
uint32_t favoriteCount = 0;
|
|
|
|
// Count favorites
|
|
for (uint32_t i = 0; i < nodeCount; i++) {
|
|
if (nodeInfoLiteIsFavorite(nodeDB->getMeshNodeByIndex(i)))
|
|
favoriteCount++;
|
|
}
|
|
|
|
// Only add favorites if the number is reasonable
|
|
// Don't want some monstrous list that takes 100 clicks to reach exit
|
|
if (favoriteCount < 20) {
|
|
for (uint32_t i = 0; i < nodeCount; i++) {
|
|
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
|
|
|
|
// Skip node if not a favorite
|
|
if (!nodeInfoLiteIsFavorite(node))
|
|
continue;
|
|
|
|
CannedMessages::RecipientItem r;
|
|
|
|
r.dest = node->num;
|
|
r.channelIndex = nodeDB->getMeshNodeChannel(node->num); // Channel index only relevant if encrypted DM not possible(?)
|
|
|
|
// Set a label for the menu item
|
|
r.label = "DM: ";
|
|
if (nodeInfoLiteHasUser(node))
|
|
r.label += parse(node->long_name);
|
|
else
|
|
r.label += hexifyNodeNum(node->num); // Unsure if it's possible to favorite a node without NodeInfo?
|
|
|
|
// Add to the list of recipients
|
|
cm.recipientItems.push_back(r);
|
|
|
|
// Add a menu item for this recipient
|
|
const char *itemText = cm.recipientItems.back().label.c_str();
|
|
items.push_back(MenuItem(itemText, SEND_CANNEDMESSAGE, MenuPage::EXIT));
|
|
}
|
|
}
|
|
|
|
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
|
}
|
|
|
|
void InkHUD::MenuApplet::drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height, const std::string &text)
|
|
{
|
|
setFont(fontSmall);
|
|
uint16_t wrapMaxH = 0;
|
|
|
|
// Draw the text, input box, and cursor
|
|
// Adjusting the box for screen height
|
|
while (wrapMaxH < height - fontSmall.lineHeight()) {
|
|
wrapMaxH += fontSmall.lineHeight();
|
|
}
|
|
|
|
// If the text is so long that it goes outside of the input box, the text is actually rendered off screen.
|
|
uint32_t textHeight = getWrappedTextHeight(0, width - 5, text);
|
|
if (!text.empty()) {
|
|
uint16_t textPadding = X(1.0) > Y(1.0) ? wrapMaxH - textHeight : wrapMaxH - textHeight + 1;
|
|
if (textHeight > wrapMaxH)
|
|
printWrapped(2, textPadding, width - 5, text);
|
|
else
|
|
printWrapped(2, top + 2, width - 5, text);
|
|
}
|
|
|
|
uint16_t textCursorX = text.empty() ? 1 : getCursorX();
|
|
uint16_t textCursorY = text.empty() ? fontSmall.lineHeight() + 2 : getCursorY() - fontSmall.lineHeight() + 3;
|
|
|
|
if (textCursorX + 1 > width - 5) {
|
|
textCursorX = getCursorX() - width + 5;
|
|
textCursorY += fontSmall.lineHeight();
|
|
}
|
|
|
|
fillRect(textCursorX + 1, textCursorY, 1, fontSmall.lineHeight(), BLACK);
|
|
|
|
// A white rectangle clears the top part of the screen for any text that's printed beyond the input box
|
|
fillRect(0, 0, X(1.0), top, WHITE);
|
|
|
|
// Draw character limit
|
|
std::string ftlen = std::to_string(text.length()) + "/" + to_string(menuTextLimit);
|
|
uint16_t textLen = getTextWidth(ftlen);
|
|
printAt(X(1.0) - textLen - 2, 0, ftlen);
|
|
|
|
// Draw the border
|
|
drawRect(0, top, width, wrapMaxH + 5, BLACK);
|
|
}
|
|
// Renders the panel shown at the top of the root menu.
|
|
// Displays the clock, and several other pieces of instantaneous system info,
|
|
// which we'd prefer not to have displayed in a normal applet, as they update too frequently.
|
|
void InkHUD::MenuApplet::drawSystemInfoPanel(int16_t left, int16_t top, uint16_t width, uint16_t *renderedHeight)
|
|
{
|
|
// Reset the height
|
|
// We'll add to this as we add elements
|
|
uint16_t height = 0;
|
|
|
|
// Clock (potentially)
|
|
// ====================
|
|
std::string clockString = getTimeString();
|
|
if (clockString.length() > 0) {
|
|
setFont(fontMedium);
|
|
printAt(width / 2, top, clockString, CENTER, TOP);
|
|
|
|
height += fontMedium.lineHeight();
|
|
height += fontMedium.lineHeight() * 0.1; // Padding below clock
|
|
}
|
|
|
|
// Stats
|
|
// ===================
|
|
|
|
setFont(fontSmall);
|
|
|
|
// Position of the label row for the system info
|
|
const int16_t labelT = top + height;
|
|
height += fontSmall.lineHeight() * 1.1; // Slightly increased spacing
|
|
|
|
// Position of the data row for the system info
|
|
const int16_t valT = top + height;
|
|
height += fontSmall.lineHeight() * 1.1; // Slightly increased spacing (between bottom line and divider)
|
|
|
|
// Position of divider between the info panel and the menu entries
|
|
const int16_t divY = top + height;
|
|
height += fontSmall.lineHeight() * 0.2; // Padding *below* the divider. (Above first menu item)
|
|
|
|
// Create a variable number of columns.
|
|
#if !MESHTASTIC_EXCLUDE_GPS && HAS_GPS
|
|
const bool showGpsInfo = config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT;
|
|
#else
|
|
const bool showGpsInfo = false;
|
|
#endif
|
|
const uint8_t columnCount = showGpsInfo ? 4 : 3;
|
|
int16_t colL[4] = {};
|
|
int16_t colC[4] = {};
|
|
int16_t colR[4] = {};
|
|
for (uint8_t i = 0; i < columnCount; i++) {
|
|
colL[i] = left + ((width / columnCount) * i);
|
|
colC[i] = colL[i] + ((width / columnCount) / 2);
|
|
colR[i] = colL[i] + (width / columnCount);
|
|
}
|
|
|
|
// Info blocks, left to right
|
|
|
|
// Voltage
|
|
float voltage = powerStatus->getBatteryVoltageMv() / 1000.0;
|
|
char voltageStr[8]; // e.g. "4.12"
|
|
snprintf(voltageStr, sizeof(voltageStr), "%.2f", voltage);
|
|
printAt(colC[0], labelT, "Bat V", CENTER, TOP);
|
|
printAt(colC[0], valT, voltageStr, CENTER, TOP);
|
|
|
|
// Divider
|
|
for (int16_t y = valT; y <= divY; y += 3)
|
|
drawPixel(colR[0], y, BLACK);
|
|
|
|
// Channel Util
|
|
char chUtilStr[8]; // e.g. "100%"
|
|
snprintf(chUtilStr, sizeof(chUtilStr), "%2.f%%", airTime->channelUtilizationPercent());
|
|
printAt(colC[1], labelT, "Ch", CENTER, TOP);
|
|
printAt(colC[1], valT, chUtilStr, CENTER, TOP);
|
|
|
|
// Divider
|
|
for (int16_t y = valT; y <= divY; y += 3)
|
|
drawPixel(colR[1], y, BLACK);
|
|
|
|
// Duty Cycle (AirTimeTx)
|
|
char dutyUtilStr[8]; // e.g. "100%"
|
|
snprintf(dutyUtilStr, sizeof(dutyUtilStr), "%2.f%%", airTime->utilizationTXPercent());
|
|
printAt(colC[2], labelT, "Duty", CENTER, TOP);
|
|
printAt(colC[2], valT, dutyUtilStr, CENTER, TOP);
|
|
|
|
if (showGpsInfo) {
|
|
// Divider
|
|
for (int16_t y = valT; y <= divY; y += 3)
|
|
drawPixel(colR[2], y, BLACK);
|
|
|
|
const bool gpsDisabled = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_DISABLED;
|
|
const char *gpsLabel = "GPS";
|
|
printAt(colC[3], labelT, gpsLabel, CENTER, TOP);
|
|
|
|
if (gpsDisabled) {
|
|
const int16_t labelW = getTextWidth(gpsLabel);
|
|
const int16_t strikeY = labelT + (fontSmall.lineHeight() / 2);
|
|
drawLine(colC[3] - (labelW / 2), strikeY, colC[3] + ((labelW - 1) / 2), strikeY, BLACK);
|
|
}
|
|
|
|
if (!gpsDisabled && gpsStatus != nullptr && gpsStatus->getIsConnected()) {
|
|
char satsStr[12];
|
|
snprintf(satsStr, sizeof(satsStr), "%lu", (unsigned long)gpsStatus->getNumSatellites());
|
|
printAt(colC[3], valT, satsStr, CENTER, TOP);
|
|
} else {
|
|
printAt(colC[3], valT, "--", CENTER, TOP);
|
|
}
|
|
}
|
|
|
|
// Horizontal divider, at bottom of system info panel
|
|
for (int16_t x = 0; x < width; x += 2) // Divider, centered in the padding between first system panel and first item
|
|
drawPixel(x, divY, BLACK);
|
|
|
|
if (renderedHeight != nullptr)
|
|
*renderedHeight = height;
|
|
}
|
|
|
|
// Get the height of the the panel drawn at the top of the menu
|
|
// This is inefficient, as we do actually have to render the panel to determine the height
|
|
// It solves a catch-22 situation, where slotCount needs to know panel height, and panel height needs to know slotCount
|
|
uint16_t InkHUD::MenuApplet::getSystemInfoPanelHeight()
|
|
{
|
|
// Render *far* off screen
|
|
uint16_t height = 0;
|
|
drawSystemInfoPanel(INT16_MIN, INT16_MIN, 1, &height);
|
|
|
|
return height;
|
|
}
|
|
|
|
// Send a text message to the mesh
|
|
// Used to send our canned messages
|
|
void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char *message)
|
|
{
|
|
meshtastic_MeshPacket *p = router->allocForSending();
|
|
if (!p)
|
|
return;
|
|
p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
|
|
p->to = dest;
|
|
p->channel = channel;
|
|
p->want_ack = true;
|
|
p->decoded.payload.size = strlen(message);
|
|
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
|
|
|
|
// Tack on a bell character if requested
|
|
if (moduleConfig.canned_message.send_bell && p->decoded.payload.size + 1 < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
|
p->decoded.payload.bytes[p->decoded.payload.size] = 7; // Bell character
|
|
p->decoded.payload.bytes[p->decoded.payload.size + 1] = '\0'; // Append Null Terminator
|
|
p->decoded.payload.size++;
|
|
}
|
|
|
|
LOG_INFO("Send message id=%d, dest=%x, msg=%.*s", p->id, p->to, p->decoded.payload.size, p->decoded.payload.bytes);
|
|
|
|
service->sendToMesh(p, RX_SRC_LOCAL, true); // Send to mesh, cc to phone
|
|
}
|
|
|
|
// Free up any heap memory we'd used while selecting / sending canned messages
|
|
void InkHUD::MenuApplet::freeCannedMessageResources()
|
|
{
|
|
cm.selectedMessageItem = nullptr;
|
|
cm.selectedRecipientItem = nullptr;
|
|
cm.messageItems.clear();
|
|
cm.recipientItems.clear();
|
|
}
|
|
#endif // MESHTASTIC_INCLUDE_INKHUD
|