* feat: sign licensed plaintext packets Preserve and publish identity keys in licensed mode so existing XEdDSA signatures can authenticate plaintext traffic. Sign licensed broadcasts and direct messages when they fit, while keeping PKI encryption disabled and normal routing behavior unchanged. * fix(security): persist licensed channel sanitation * fix(security): close licensed migration lifecycle gaps * fix(security): address licensed signing review feedback * test: restore signing globals from Unity teardown * fix(baseui): allow confirmed ham region selection * fix(baseui): guard region picker validation * style: trunk fmt --------- Co-authored-by: Ben Meadors <benmmeadors@gmail.com> Co-authored-by: Austin <vidplace7@gmail.com>
2934 lines
113 KiB
C++
2934 lines
113 KiB
C++
#include "configuration.h"
|
|
#if HAS_SCREEN
|
|
#include "ClockRenderer.h"
|
|
#include "Default.h"
|
|
#include "DisplayFormatters.h"
|
|
#include "GPS.h"
|
|
#include "MenuHandler.h"
|
|
#include "MeshRadio.h"
|
|
#include "MeshService.h"
|
|
#include "MessageStore.h"
|
|
#include "NodeDB.h"
|
|
#include "buzz.h"
|
|
#include "graphics/Screen.h"
|
|
#include "graphics/SharedUIDisplay.h"
|
|
#include "graphics/TFTColorRegions.h"
|
|
#include "graphics/draw/MessageRenderer.h"
|
|
#include "graphics/draw/UIRenderer.h"
|
|
#include "input/RotaryEncoderInterruptImpl1.h"
|
|
#include "input/UpDownInterruptImpl1.h"
|
|
#include "main.h"
|
|
#include "mesh/Default.h"
|
|
#include "mesh/MeshTypes.h"
|
|
#include "mesh/RadioLibInterface.h"
|
|
#include "modules/AdminModule.h"
|
|
#include "modules/CannedMessageModule.h"
|
|
#include "modules/ExternalNotificationModule.h"
|
|
#include "modules/KeyVerificationModule.h"
|
|
#include "modules/TraceRouteModule.h"
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cmath>
|
|
#include <functional>
|
|
#include <utility>
|
|
|
|
namespace graphics
|
|
{
|
|
|
|
namespace
|
|
{
|
|
|
|
// Caller must ensure the provided options array outlives the banner callback.
|
|
template <typename T, size_t N, typename Callback>
|
|
BannerOverlayOptions createStaticBannerOptions(const char *message, const MenuOption<T> (&options)[N],
|
|
std::array<const char *, N> &labels, Callback &&onSelection)
|
|
{
|
|
for (size_t i = 0; i < N; ++i) {
|
|
labels[i] = options[i].label;
|
|
}
|
|
|
|
const MenuOption<T> *optionsPtr = options;
|
|
auto callback = std::function<void(const MenuOption<T> &, int)>(std::forward<Callback>(onSelection));
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = message;
|
|
bannerOptions.optionsArrayPtr = labels.data();
|
|
bannerOptions.optionsCount = static_cast<uint8_t>(N);
|
|
bannerOptions.bannerCallback = [optionsPtr, callback](int selected) -> void { callback(optionsPtr[selected], selected); };
|
|
return bannerOptions;
|
|
}
|
|
|
|
const StoredMessage *getNewestMessageForActiveThread()
|
|
{
|
|
const auto &messages = messageStore.getMessages();
|
|
if (messages.empty()) {
|
|
return nullptr;
|
|
}
|
|
|
|
const auto mode = graphics::MessageRenderer::getThreadMode();
|
|
const int channel = graphics::MessageRenderer::getThreadChannel();
|
|
const uint32_t peer = graphics::MessageRenderer::getThreadPeer();
|
|
const uint32_t localNode = nodeDB->getNodeNum();
|
|
|
|
for (auto it = messages.rbegin(); it != messages.rend(); ++it) {
|
|
const StoredMessage &m = *it;
|
|
if (!messageStore.isMessageVisible(m)) {
|
|
continue;
|
|
}
|
|
|
|
if (mode == graphics::MessageRenderer::ThreadMode::ALL) {
|
|
return &m;
|
|
}
|
|
|
|
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
|
|
if (m.type == MessageType::BROADCAST && static_cast<int>(m.channelIndex) == channel) {
|
|
return &m;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) {
|
|
if (m.type != MessageType::DM_TO_US) {
|
|
continue;
|
|
}
|
|
const uint32_t other = (m.sender == localNode) ? m.dest : m.sender;
|
|
if (other == peer) {
|
|
return &m;
|
|
}
|
|
}
|
|
}
|
|
|
|
return nullptr;
|
|
}
|
|
|
|
void launchReplyForMessage(const StoredMessage &message, bool freetext)
|
|
{
|
|
if (message.type == MessageType::BROADCAST || message.dest == NODENUM_BROADCAST) {
|
|
if (freetext) {
|
|
cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, message.channelIndex);
|
|
} else {
|
|
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, message.channelIndex);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const uint32_t localNode = nodeDB->getNodeNum();
|
|
const uint32_t peer = (message.sender == localNode) ? message.dest : message.sender;
|
|
if (peer == 0 || peer == NODENUM_BROADCAST) {
|
|
return;
|
|
}
|
|
|
|
if (freetext) {
|
|
cannedMessageModule->LaunchFreetextWithDestination(peer);
|
|
} else {
|
|
cannedMessageModule->LaunchWithDestination(peer);
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
|
|
menuHandler::screenMenus menuHandler::menuQueue = MenuNone;
|
|
uint32_t menuHandler::pickedNodeNum = 0;
|
|
meshtastic_Config_LoRaConfig_RegionCode menuHandler::pendingRegion = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
|
bool test_enabled = false;
|
|
uint8_t test_count = 0;
|
|
|
|
void menuHandler::loraMenu()
|
|
{
|
|
static const char *optionsArray[] = {"Back", "Device Role", "Radio Preset", "Frequency Slot", "LoRa Region"};
|
|
enum optionsNumbers { Back = 0, DeviceRolePicker = 1, RadioPresetPicker = 2, FrequencySlot = 3, LoraPicker = 4 };
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "LoRa Actions";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 5;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == Back) {
|
|
// No action
|
|
} else if (selected == DeviceRolePicker) {
|
|
menuHandler::menuQueue = menuHandler::DeviceRolePicker;
|
|
} else if (selected == RadioPresetPicker) {
|
|
menuHandler::menuQueue = menuHandler::RadioPresetPicker;
|
|
} else if (selected == FrequencySlot) {
|
|
menuHandler::menuQueue = menuHandler::FrequencySlot;
|
|
} else if (selected == LoraPicker) {
|
|
menuHandler::menuQueue = menuHandler::LoraPicker;
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::OnboardMessage()
|
|
{
|
|
static const char *optionsArray[] = {"OK", "Got it!"};
|
|
enum optionsNumbers { OK, got };
|
|
BannerOverlayOptions bannerOptions;
|
|
#if HAS_TFT
|
|
bannerOptions.message = "Welcome to Meshtastic!\nSwipe to navigate and\nlong press to select\nor open a menu.";
|
|
#elif defined(BUTTON_PIN)
|
|
bannerOptions.message = "Welcome to Meshtastic!\nClick to navigate and\nlong press to select\nor open a menu.";
|
|
#else
|
|
bannerOptions.message = "Welcome to Meshtastic!\nUse the Select button\nto open menus\nand make selections.";
|
|
#endif
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 2;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
menuHandler::menuQueue = menuHandler::NoTimeoutLoraPicker;
|
|
screen->runNow();
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool isHam)
|
|
{
|
|
config.lora.region = region;
|
|
config.lora.channel_num = 0; // Reset to default channel
|
|
|
|
// Reconcile the preset with the explicitly chosen region: a preset locked to another
|
|
// region would leave config.lora invalid until applyModemConfig() repairs it with
|
|
// error/critical-error side effects - or, for the swappable EU trio, the clamp would
|
|
// flip the region right back. The user picked the region, so the preset follows it.
|
|
const RegionInfo *newRegion = getRegion(region);
|
|
if (config.lora.use_preset && !newRegion->supportsPreset(config.lora.modem_preset)) {
|
|
LOG_INFO("Preset %s not available in %s, using default %s",
|
|
DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true), newRegion->name,
|
|
DisplayFormatters::getModemPresetDisplayName(newRegion->getDefaultPreset(), false, true));
|
|
config.lora.modem_preset = newRegion->getDefaultPreset();
|
|
}
|
|
|
|
if (isHam && adminModule) {
|
|
meshtastic_HamParameters hamParams = meshtastic_HamParameters_init_zero;
|
|
strncpy(hamParams.call_sign, "N0CALL", sizeof(hamParams.call_sign) - 1);
|
|
strncpy(hamParams.short_name, "N0CL", sizeof(hamParams.short_name));
|
|
hamParams.tx_power = config.lora.tx_power;
|
|
hamParams.frequency = config.lora.override_frequency;
|
|
adminModule->handleSetHamMode(hamParams);
|
|
}
|
|
auto changes = SEGMENT_CONFIG;
|
|
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
|
if (crypto) {
|
|
crypto->ensurePkiKeys(config.security, owner);
|
|
}
|
|
#endif
|
|
initRegion();
|
|
if (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;
|
|
}
|
|
#if !MESHTASTIC_EXCLUDE_GPS
|
|
// Enable gps if it was previously disabled due to region not being set
|
|
if (gps != nullptr && !gps->isEnabled() && config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED)
|
|
gps->enable();
|
|
#endif
|
|
service->reloadConfig(changes);
|
|
}
|
|
|
|
void menuHandler::LoraRegionPicker(uint32_t duration)
|
|
{
|
|
static const LoraRegionOption regionOptions[] = {
|
|
{"Back", OptionsAction::Back},
|
|
{"US", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_US},
|
|
{"EU_433", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_433},
|
|
{"EU_868", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_868},
|
|
{"EU_866", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_866},
|
|
{"EU_868_NARROW", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_N_868},
|
|
{"CN", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_CN},
|
|
{"JP", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_JP},
|
|
{"ANZ", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ANZ},
|
|
{"KR", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_KR},
|
|
{"TW", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_TW},
|
|
{"RU", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_RU},
|
|
{"IN", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_IN},
|
|
{"NZ_865", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_NZ_865},
|
|
{"TH", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_TH},
|
|
{"LORA_24", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_LORA_24},
|
|
{"UA_433", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_UA_433},
|
|
{"MY_433", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_MY_433},
|
|
{"MY_919", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_MY_919},
|
|
{"SG_923", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_SG_923},
|
|
{"PH_433", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_PH_433},
|
|
{"PH_868", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_PH_868},
|
|
{"PH_915", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_PH_915},
|
|
{"ANZ_433", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ANZ_433},
|
|
{"KZ_433", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_KZ_433},
|
|
{"KZ_863", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_KZ_863},
|
|
{"NP_865", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_NP_865},
|
|
{"BR_902", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_BR_902},
|
|
{"ITU1_2M (144-146)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M},
|
|
{"ITU2_2M (144-148)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU2_2M},
|
|
{"ITU3_2M (144-148)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU3_2M},
|
|
{"ITU2_125CM (220-225)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM},
|
|
{"ITU1_70CM (430-440)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU1_70CM},
|
|
{"ITU2_70CM (420-450)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU2_70CM},
|
|
{"ITU3_70CM (430-450)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU3_70CM},
|
|
|
|
};
|
|
|
|
constexpr size_t regionCount = sizeof(regionOptions) / sizeof(regionOptions[0]);
|
|
static std::array<const char *, regionCount> regionLabels{};
|
|
|
|
const char *bannerMessage = "Set the LoRa region";
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
bannerMessage = "LoRa Region";
|
|
}
|
|
|
|
auto bannerOptions =
|
|
createStaticBannerOptions(bannerMessage, regionOptions, regionLabels, [](const LoraRegionOption &option, int) -> void {
|
|
if (!option.hasValue) {
|
|
return;
|
|
}
|
|
|
|
auto selectedRegion = option.value;
|
|
if (config.lora.region == selectedRegion) {
|
|
return;
|
|
}
|
|
|
|
const RegionInfo *selectedRegionInfo = getRegion(selectedRegion);
|
|
bool hamMode = selectedRegionInfo->code == selectedRegion && selectedRegionInfo->profile &&
|
|
selectedRegionInfo->profile->licensedOnly;
|
|
|
|
// Validate radio compatibility for a prospective Ham region before confirmation.
|
|
auto candidateLora = config.lora;
|
|
candidateLora.region = selectedRegion;
|
|
char regionErr[160] = {};
|
|
if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr), hamMode)) {
|
|
LOG_WARN("Ignoring region selection: %s", regionErr);
|
|
return;
|
|
}
|
|
|
|
if (hamMode) {
|
|
LOG_INFO("User chose an amateur radio mode region");
|
|
pendingRegion = selectedRegion;
|
|
menuQueue = HamModeConfirm;
|
|
screen->runNow();
|
|
} else if (owner.is_licensed) {
|
|
LOG_INFO("Licensed user chose a non-ham region; prompting to revert licensed mode");
|
|
pendingRegion = selectedRegion;
|
|
menuQueue = LicensedToNormalConfirm;
|
|
screen->runNow();
|
|
} else {
|
|
applyLoraRegion(selectedRegion, false);
|
|
}
|
|
});
|
|
|
|
bannerOptions.durationMs = duration;
|
|
|
|
int initialSelection = 0;
|
|
for (size_t i = 0; i < regionCount; ++i) {
|
|
if (regionOptions[i].hasValue && regionOptions[i].value == config.lora.region) {
|
|
initialSelection = static_cast<int>(i);
|
|
break;
|
|
}
|
|
}
|
|
bannerOptions.InitialSelected = initialSelection;
|
|
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::hamModeConfirmMenu()
|
|
{
|
|
static const char *confirmOptions[] = {"No", "Yes"};
|
|
BannerOverlayOptions confirmBanner;
|
|
confirmBanner.message = "I confirm I am a\nlicensed amateur\nradio operator";
|
|
confirmBanner.optionsArrayPtr = confirmOptions;
|
|
confirmBanner.optionsCount = 2;
|
|
confirmBanner.bannerCallback = [](int selected) {
|
|
if (selected == 1)
|
|
applyLoraRegion(pendingRegion, true);
|
|
};
|
|
screen->showOverlayBanner(confirmBanner);
|
|
}
|
|
|
|
void menuHandler::licensedToNormalConfirmMenu()
|
|
{
|
|
static const char *confirmOptions[] = {"Keep licensed", "Revert to Normal"};
|
|
BannerOverlayOptions confirmBanner;
|
|
confirmBanner.message = "Revert licensed\nmode? This will\nre-enable encryption.";
|
|
confirmBanner.optionsArrayPtr = confirmOptions;
|
|
confirmBanner.optionsCount = 2;
|
|
confirmBanner.bannerCallback = [](int selected) {
|
|
if (selected == 1) {
|
|
owner.is_licensed = false;
|
|
config.lora.override_duty_cycle = false;
|
|
service->reloadOwner(false);
|
|
}
|
|
applyLoraRegion(pendingRegion, false);
|
|
};
|
|
screen->showOverlayBanner(confirmBanner);
|
|
}
|
|
|
|
void menuHandler::deviceRolePicker()
|
|
{
|
|
static const char *optionsArray[] = {"Back", "Client", "Client Mute", "Lost and Found", "Tracker"};
|
|
enum optionsNumbers {
|
|
Back = 0,
|
|
devicerole_client = 1,
|
|
devicerole_clientmute = 2,
|
|
devicerole_lostandfound = 3,
|
|
devicerole_tracker = 4
|
|
};
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Device Role";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 5;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == Back) {
|
|
menuHandler::menuQueue = menuHandler::LoraMenu;
|
|
screen->runNow();
|
|
return;
|
|
} else if (selected == devicerole_client) {
|
|
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
|
} else if (selected == devicerole_clientmute) {
|
|
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE;
|
|
} else if (selected == devicerole_lostandfound) {
|
|
config.device.role = meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND;
|
|
} else if (selected == devicerole_tracker) {
|
|
config.device.role = meshtastic_Config_DeviceConfig_Role_TRACKER;
|
|
}
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::FrequencySlotPicker()
|
|
{
|
|
|
|
enum ReplyOptions : int { Back = -1 };
|
|
constexpr int MAX_CHANNEL_OPTIONS = 202;
|
|
static const char *optionsArray[MAX_CHANNEL_OPTIONS];
|
|
static int optionsEnumArray[MAX_CHANNEL_OPTIONS];
|
|
static char channelText[MAX_CHANNEL_OPTIONS - 1][12];
|
|
int options = 0;
|
|
optionsArray[options] = "Back";
|
|
optionsEnumArray[options++] = Back;
|
|
optionsArray[options] = "Slot 0 (Auto)";
|
|
optionsEnumArray[options++] = 0;
|
|
|
|
// Calculate number of channels (copied from RadioInterface::applyModemConfig())
|
|
|
|
meshtastic_Config_LoRaConfig &loraConfig = config.lora;
|
|
double bw = loraConfig.use_preset ? modemPresetToBwKHz(loraConfig.modem_preset, myRegion->wideLora)
|
|
: bwCodeToKHz(loraConfig.bandwidth);
|
|
|
|
uint32_t numChannels = 0;
|
|
if (myRegion) {
|
|
// Match RadioInterface::applyModemConfig(): include padding, add spacing in numerator, and use round()
|
|
const double spacing = myRegion->profile->spacing;
|
|
const double padding = myRegion->profile->padding;
|
|
const double channelBandwidthMHz = bw / 1000.0;
|
|
const double numerator = (myRegion->freqEnd - myRegion->freqStart) + spacing;
|
|
const double denominator = spacing + (padding * 2) + channelBandwidthMHz;
|
|
if (denominator > 0.0) {
|
|
numChannels = static_cast<uint32_t>(round(numerator / denominator));
|
|
} else {
|
|
LOG_WARN("Invalid region configuration: non-positive channel spacing/width");
|
|
}
|
|
} else {
|
|
LOG_WARN("Region not set, cannot calculate number of channels");
|
|
return;
|
|
}
|
|
|
|
if (numChannels > (uint32_t)(MAX_CHANNEL_OPTIONS - 2))
|
|
numChannels = (uint32_t)(MAX_CHANNEL_OPTIONS - 2);
|
|
|
|
for (uint32_t ch = 1; ch <= numChannels; ch++) {
|
|
snprintf(channelText[ch - 1], sizeof(channelText[ch - 1]), "Slot %lu", (unsigned long)ch);
|
|
optionsArray[options] = channelText[ch - 1];
|
|
optionsEnumArray[options++] = (int)ch;
|
|
}
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Frequency Slot";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
|
bannerOptions.optionsCount = options;
|
|
|
|
// Start highlight on current channel if possible, otherwise on "1"
|
|
int initial = (int)config.lora.channel_num + 1;
|
|
if (initial < 2 || initial > (int)numChannels + 1)
|
|
initial = 1;
|
|
bannerOptions.InitialSelected = initial;
|
|
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == Back) {
|
|
menuHandler::menuQueue = menuHandler::LoraMenu;
|
|
screen->runNow();
|
|
return;
|
|
}
|
|
|
|
config.lora.channel_num = selected;
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
};
|
|
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
// Maximum presets any region can have + 1 for Back
|
|
static constexpr int MAX_PRESET_OPTIONS = 16;
|
|
|
|
static BannerOverlayOptions buildRegionPresetBanner()
|
|
{
|
|
// Static storage reused each call - safe because the banner is shown immediately after.
|
|
static const char *optionsArray[MAX_PRESET_OPTIONS];
|
|
static int optionsEnumArray[MAX_PRESET_OPTIONS];
|
|
static char presetLabelBuf[MAX_PRESET_OPTIONS][12]; // scratch space for name copies
|
|
int count = 0;
|
|
|
|
optionsArray[count] = "Back";
|
|
optionsEnumArray[count++] = -1;
|
|
|
|
if (myRegion && myRegion->profile) {
|
|
const meshtastic_Config_LoRaConfig_ModemPreset *presets = myRegion->getAvailablePresets();
|
|
size_t numPresets = myRegion->getNumPresets();
|
|
for (size_t i = 0; i < numPresets && count < MAX_PRESET_OPTIONS; ++i) {
|
|
const char *name = DisplayFormatters::getModemPresetDisplayName(presets[i], false, true);
|
|
strncpy(presetLabelBuf[count], name, sizeof(presetLabelBuf[count]) - 1);
|
|
presetLabelBuf[count][sizeof(presetLabelBuf[count]) - 1] = '\0';
|
|
optionsArray[count] = presetLabelBuf[count];
|
|
optionsEnumArray[count++] = static_cast<int>(presets[i]);
|
|
}
|
|
}
|
|
|
|
int initialSelection = 0;
|
|
for (int i = 1; i < count; ++i) {
|
|
if (optionsEnumArray[i] == static_cast<int>(config.lora.modem_preset)) {
|
|
initialSelection = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Radio Preset";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
|
bannerOptions.optionsCount = static_cast<uint8_t>(count);
|
|
bannerOptions.InitialSelected = initialSelection;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == -1) {
|
|
menuHandler::menuQueue = menuHandler::LoraMenu;
|
|
screen->runNow();
|
|
return;
|
|
}
|
|
config.lora.use_preset = true;
|
|
config.lora.modem_preset = static_cast<meshtastic_Config_LoRaConfig_ModemPreset>(selected);
|
|
config.lora.channel_num = 0; // Reset to default channel for the preset
|
|
config.lora.override_frequency = 0; // Clear any custom frequency
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
};
|
|
return bannerOptions;
|
|
}
|
|
|
|
void menuHandler::radioPresetPicker()
|
|
{
|
|
screen->showOverlayBanner(buildRegionPresetBanner());
|
|
}
|
|
|
|
void menuHandler::twelveHourPicker()
|
|
{
|
|
static const char *optionsArray[] = {"Back", "12-hour", "24-hour"};
|
|
enum optionsNumbers { Back = 0, twelve = 1, twentyfour = 2 };
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Time Format";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 3;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == Back) {
|
|
menuHandler::menuQueue = menuHandler::ClockMenu;
|
|
screen->runNow();
|
|
} else if (selected == twelve) {
|
|
config.display.use_12h_clock = true;
|
|
} else {
|
|
config.display.use_12h_clock = false;
|
|
}
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
// Reusable confirmation prompt function
|
|
void menuHandler::showConfirmationBanner(const char *message, std::function<void()> onConfirm)
|
|
{
|
|
static const char *confirmOptions[] = {"No", "Yes"};
|
|
BannerOverlayOptions confirmBanner;
|
|
confirmBanner.message = message;
|
|
confirmBanner.optionsArrayPtr = confirmOptions;
|
|
confirmBanner.optionsCount = 2;
|
|
confirmBanner.bannerCallback = [onConfirm](int confirmSelected) -> void {
|
|
if (confirmSelected == 1) {
|
|
onConfirm();
|
|
}
|
|
};
|
|
screen->showOverlayBanner(confirmBanner);
|
|
}
|
|
|
|
void menuHandler::clockFacePicker()
|
|
{
|
|
static const ClockFaceOption clockFaceOptions[] = {
|
|
{"Back", OptionsAction::Back},
|
|
{"Digital", OptionsAction::Select, false},
|
|
{"Analog", OptionsAction::Select, true},
|
|
};
|
|
|
|
constexpr size_t clockFaceCount = sizeof(clockFaceOptions) / sizeof(clockFaceOptions[0]);
|
|
static std::array<const char *, clockFaceCount> clockFaceLabels{};
|
|
|
|
auto bannerOptions = createStaticBannerOptions("Which Face?", clockFaceOptions, clockFaceLabels,
|
|
[](const ClockFaceOption &option, int) -> void {
|
|
if (option.action == OptionsAction::Back) {
|
|
menuHandler::menuQueue = menuHandler::ClockMenu;
|
|
screen->runNow();
|
|
return;
|
|
}
|
|
|
|
if (!option.hasValue) {
|
|
return;
|
|
}
|
|
|
|
if (uiconfig.is_clockface_analog == option.value) {
|
|
return;
|
|
}
|
|
|
|
uiconfig.is_clockface_analog = option.value;
|
|
saveUIConfig();
|
|
screen->setFrames(Screen::FOCUS_CLOCK);
|
|
});
|
|
|
|
bannerOptions.InitialSelected = uiconfig.is_clockface_analog ? 2 : 1;
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::TZPicker()
|
|
{
|
|
static const TimezoneOption timezoneOptions[] = {
|
|
{"Back", OptionsAction::Back},
|
|
{"US/Hawaii", OptionsAction::Select, "HST10"},
|
|
{"US/Alaska", OptionsAction::Select, "AKST9AKDT,M3.2.0,M11.1.0"},
|
|
{"US/Pacific", OptionsAction::Select, "PST8PDT,M3.2.0,M11.1.0"},
|
|
{"US/Arizona", OptionsAction::Select, "MST7"},
|
|
{"US/Mountain", OptionsAction::Select, "MST7MDT,M3.2.0,M11.1.0"},
|
|
{"US/Central", OptionsAction::Select, "CST6CDT,M3.2.0,M11.1.0"},
|
|
{"US/Eastern", OptionsAction::Select, "EST5EDT,M3.2.0,M11.1.0"},
|
|
{"BR/Brasilia", OptionsAction::Select, "BRT3"},
|
|
{"UTC", OptionsAction::Select, "UTC0"},
|
|
{"EU/Western", OptionsAction::Select, "GMT0BST,M3.5.0/1,M10.5.0"},
|
|
{"EU/Central", OptionsAction::Select, "CET-1CEST,M3.5.0,M10.5.0/3"},
|
|
{"EU/Eastern", OptionsAction::Select, "EET-2EEST,M3.5.0/3,M10.5.0/4"},
|
|
{"Asia/Kolkata", OptionsAction::Select, "IST-5:30"},
|
|
{"Asia/Hong_Kong", OptionsAction::Select, "HKT-8"},
|
|
{"AU/AWST", OptionsAction::Select, "AWST-8"},
|
|
{"AU/ACST", OptionsAction::Select, "ACST-9:30ACDT,M10.1.0,M4.1.0/3"},
|
|
{"AU/AEST", OptionsAction::Select, "AEST-10AEDT,M10.1.0,M4.1.0/3"},
|
|
{"Pacific/NZ", OptionsAction::Select, "NZST-12NZDT,M9.5.0,M4.1.0/3"},
|
|
};
|
|
|
|
constexpr size_t timezoneCount = sizeof(timezoneOptions) / sizeof(timezoneOptions[0]);
|
|
static std::array<const char *, timezoneCount> timezoneLabels{};
|
|
|
|
auto bannerOptions = createStaticBannerOptions(
|
|
"Pick Timezone", timezoneOptions, timezoneLabels, [](const TimezoneOption &option, int) -> void {
|
|
if (option.action == OptionsAction::Back) {
|
|
menuHandler::menuQueue = menuHandler::ClockMenu;
|
|
screen->runNow();
|
|
return;
|
|
}
|
|
|
|
if (!option.hasValue) {
|
|
return;
|
|
}
|
|
|
|
if (strncmp(config.device.tzdef, option.value, sizeof(config.device.tzdef)) == 0) {
|
|
return;
|
|
}
|
|
|
|
strncpy(config.device.tzdef, option.value, sizeof(config.device.tzdef));
|
|
config.device.tzdef[sizeof(config.device.tzdef) - 1] = '\0';
|
|
|
|
setenv("TZ", config.device.tzdef, 1);
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
});
|
|
|
|
int initialSelection = 0;
|
|
for (size_t i = 0; i < timezoneCount; ++i) {
|
|
if (timezoneOptions[i].hasValue &&
|
|
strncmp(config.device.tzdef, timezoneOptions[i].value, sizeof(config.device.tzdef)) == 0) {
|
|
initialSelection = static_cast<int>(i);
|
|
break;
|
|
}
|
|
}
|
|
bannerOptions.InitialSelected = initialSelection;
|
|
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::clockMenu()
|
|
{
|
|
#if defined(OLED_TINY)
|
|
static const char *optionsArray[] = {"Back", "Time Format", "Timezone"};
|
|
#else
|
|
static const char *optionsArray[] = {"Back", "Clock Face", "Time Format", "Timezone"};
|
|
#endif
|
|
enum optionsNumbers { Back = 0, Clock = 1, Time = 2, Timezone = 3 };
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Clock Action";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 4;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == Clock) {
|
|
menuHandler::menuQueue = menuHandler::ClockFacePicker;
|
|
screen->runNow();
|
|
} else if (selected == Time) {
|
|
menuHandler::menuQueue = menuHandler::TwelveHourPicker;
|
|
screen->runNow();
|
|
} else if (selected == Timezone) {
|
|
menuHandler::menuQueue = menuHandler::TzPicker;
|
|
screen->runNow();
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
void menuHandler::messageResponseMenu()
|
|
{
|
|
enum optionsNumbers { Back = 0, ViewMode, DeleteMenu, ReplyMenu, MuteChannel, Aloud, enumEnd };
|
|
|
|
static const char *optionsArray[enumEnd];
|
|
static int optionsEnumArray[enumEnd];
|
|
int options = 0;
|
|
|
|
auto mode = graphics::MessageRenderer::getThreadMode();
|
|
int threadChannel = graphics::MessageRenderer::getThreadChannel();
|
|
|
|
optionsArray[options] = "Back";
|
|
optionsEnumArray[options++] = Back;
|
|
|
|
// New Reply submenu (replaces Preset and Freetext directly in this menu)
|
|
optionsArray[options] = "Reply";
|
|
optionsEnumArray[options++] = ReplyMenu;
|
|
|
|
optionsArray[options] = "View Chats";
|
|
optionsEnumArray[options++] = ViewMode;
|
|
|
|
// If viewing ALL chats, hide “Mute Chat”
|
|
if (mode != graphics::MessageRenderer::ThreadMode::ALL && mode != graphics::MessageRenderer::ThreadMode::DIRECT) {
|
|
const uint8_t chIndex = (threadChannel != 0) ? (uint8_t)threadChannel : channels.getPrimaryIndex();
|
|
const auto &chan = channels.getByIndex(chIndex);
|
|
|
|
optionsArray[options] = chan.settings.module_settings.is_muted ? "Unmute Channel" : "Mute Channel";
|
|
optionsEnumArray[options++] = MuteChannel;
|
|
}
|
|
|
|
// Delete submenu
|
|
optionsArray[options] = "Delete";
|
|
optionsEnumArray[options++] = DeleteMenu;
|
|
|
|
#ifdef HAS_I2S
|
|
optionsArray[options] = "Read Aloud";
|
|
optionsEnumArray[options++] = Aloud;
|
|
#endif
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Message Action";
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
bannerOptions.message = "Message";
|
|
}
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
|
bannerOptions.optionsCount = options;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
LOG_DEBUG("messageResponseMenu: selected %d", selected);
|
|
|
|
auto mode = graphics::MessageRenderer::getThreadMode();
|
|
int ch = graphics::MessageRenderer::getThreadChannel();
|
|
uint32_t peer = graphics::MessageRenderer::getThreadPeer();
|
|
|
|
LOG_DEBUG("[ReplyCtx] mode=%d ch=%d peer=0x%08x", (int)mode, ch, (unsigned int)peer);
|
|
|
|
if (selected == ViewMode) {
|
|
menuHandler::menuQueue = menuHandler::MessageViewModeMenu;
|
|
screen->runNow();
|
|
|
|
// Reply submenu
|
|
} else if (selected == ReplyMenu) {
|
|
menuHandler::menuQueue = menuHandler::ReplyMenu;
|
|
screen->runNow();
|
|
|
|
} else if (selected == MuteChannel) {
|
|
const uint8_t chIndex = (ch != 0) ? (uint8_t)ch : channels.getPrimaryIndex();
|
|
auto &chan = channels.getByIndex(chIndex);
|
|
if (chan.settings.has_module_settings) {
|
|
chan.settings.module_settings.is_muted = !chan.settings.module_settings.is_muted;
|
|
nodeDB->saveToDisk();
|
|
}
|
|
|
|
} else if (selected == DeleteMenu) {
|
|
menuHandler::menuQueue = menuHandler::DeleteMessagesMenu;
|
|
screen->runNow();
|
|
|
|
#ifdef HAS_I2S
|
|
} else if (selected == Aloud) {
|
|
if (const StoredMessage *latest = getNewestMessageForActiveThread()) {
|
|
const char *msg = MessageStore::getText(*latest);
|
|
if (msg && msg[0]) {
|
|
audioThread->readAloud(msg);
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::replyMenu()
|
|
{
|
|
enum replyOptions { Back = 0, ReplyPreset, ReplyFreetext, enumEnd };
|
|
|
|
static const char *optionsArray[enumEnd];
|
|
static int optionsEnumArray[enumEnd];
|
|
int options = 0;
|
|
|
|
// Back
|
|
optionsArray[options] = "Back";
|
|
optionsEnumArray[options++] = Back;
|
|
|
|
// Preset reply
|
|
optionsArray[options] = "With Preset";
|
|
optionsEnumArray[options++] = ReplyPreset;
|
|
|
|
// Freetext reply (only when keyboard exists)
|
|
if (kb_found) {
|
|
optionsArray[options] = "With Freetext";
|
|
optionsEnumArray[options++] = ReplyFreetext;
|
|
}
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
|
|
// Dynamic title based on thread mode
|
|
auto mode = graphics::MessageRenderer::getThreadMode();
|
|
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
|
|
bannerOptions.message = "Reply to Channel";
|
|
} else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) {
|
|
bannerOptions.message = "Reply to DM";
|
|
} else {
|
|
// View All
|
|
bannerOptions.message = "Reply to Last Msg";
|
|
}
|
|
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
|
bannerOptions.optionsCount = options;
|
|
bannerOptions.InitialSelected = 1;
|
|
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
auto mode = graphics::MessageRenderer::getThreadMode();
|
|
int ch = graphics::MessageRenderer::getThreadChannel();
|
|
uint32_t peer = graphics::MessageRenderer::getThreadPeer();
|
|
|
|
if (selected == Back) {
|
|
menuHandler::menuQueue = menuHandler::MessageResponseMenu;
|
|
screen->runNow();
|
|
return;
|
|
}
|
|
|
|
// Preset reply
|
|
if (selected == ReplyPreset) {
|
|
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
|
|
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, ch);
|
|
} else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) {
|
|
cannedMessageModule->LaunchWithDestination(peer);
|
|
} else if (const StoredMessage *latest = getNewestMessageForActiveThread()) {
|
|
launchReplyForMessage(*latest, false);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Freetext reply
|
|
if (selected == ReplyFreetext) {
|
|
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
|
|
cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, ch);
|
|
} else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) {
|
|
cannedMessageModule->LaunchFreetextWithDestination(peer);
|
|
} else if (const StoredMessage *latest = getNewestMessageForActiveThread()) {
|
|
launchReplyForMessage(*latest, true);
|
|
}
|
|
|
|
return;
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
void menuHandler::deleteMessagesMenu()
|
|
{
|
|
enum optionsNumbers { Back = 0, DeleteOldest, DeleteThis, DeleteAll, enumEnd };
|
|
|
|
static const char *optionsArray[enumEnd];
|
|
static int optionsEnumArray[enumEnd];
|
|
int options = 0;
|
|
|
|
auto mode = graphics::MessageRenderer::getThreadMode();
|
|
|
|
optionsArray[options] = "Back";
|
|
optionsEnumArray[options++] = Back;
|
|
|
|
optionsArray[options] = "Delete Oldest";
|
|
optionsEnumArray[options++] = DeleteOldest;
|
|
|
|
// If viewing ALL chats → hide “Delete This Chat”
|
|
if (mode != graphics::MessageRenderer::ThreadMode::ALL) {
|
|
optionsArray[options] = "Delete This Chat";
|
|
optionsEnumArray[options++] = DeleteThis;
|
|
}
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
optionsArray[options] = "Delete All";
|
|
} else {
|
|
optionsArray[options] = "Delete All Chats";
|
|
}
|
|
optionsEnumArray[options++] = DeleteAll;
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Delete Messages";
|
|
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
|
bannerOptions.optionsCount = options;
|
|
bannerOptions.bannerCallback = [mode](int selected) -> void {
|
|
int ch = graphics::MessageRenderer::getThreadChannel();
|
|
uint32_t peer = graphics::MessageRenderer::getThreadPeer();
|
|
|
|
if (selected == Back) {
|
|
menuHandler::menuQueue = menuHandler::MessageResponseMenu;
|
|
screen->runNow();
|
|
return;
|
|
}
|
|
|
|
if (selected == DeleteAll) {
|
|
LOG_INFO("Deleting all messages");
|
|
messageStore.clearAllMessages();
|
|
graphics::MessageRenderer::clearThreadRegistries();
|
|
graphics::MessageRenderer::clearMessageCache();
|
|
return;
|
|
}
|
|
|
|
if (selected == DeleteOldest) {
|
|
LOG_INFO("Deleting oldest message");
|
|
|
|
if (mode == graphics::MessageRenderer::ThreadMode::ALL) {
|
|
messageStore.deleteOldestMessage();
|
|
} else if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
|
|
messageStore.deleteOldestMessageInChannel(ch);
|
|
} else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) {
|
|
messageStore.deleteOldestMessageWithPeer(peer);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// This only appears in non-ALL modes
|
|
if (selected == DeleteThis) {
|
|
LOG_INFO("Deleting all messages in this thread");
|
|
|
|
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) {
|
|
messageStore.deleteAllMessagesInChannel(ch);
|
|
} else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) {
|
|
messageStore.deleteAllMessagesWithPeer(peer);
|
|
}
|
|
return;
|
|
}
|
|
};
|
|
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::messageViewModeMenu()
|
|
{
|
|
auto encodeChannelId = [](int ch) -> int { return 100 + ch; };
|
|
auto isChannelSel = [](int id) -> bool { return id >= 100 && id < 200; };
|
|
|
|
static std::vector<std::string> labels;
|
|
static std::vector<int> ids;
|
|
static std::vector<uint32_t> idToPeer; // DM lookup
|
|
|
|
labels.clear();
|
|
ids.clear();
|
|
idToPeer.clear();
|
|
|
|
labels.push_back("Back");
|
|
ids.push_back(-1);
|
|
labels.push_back("View All Chats");
|
|
ids.push_back(-2);
|
|
|
|
// Channels with messages
|
|
for (int ch = 0; ch < 8; ++ch) {
|
|
auto msgs = messageStore.getChannelMessages((uint8_t)ch);
|
|
if (!msgs.empty()) {
|
|
char buf[40];
|
|
const char *cname = channels.getName(ch);
|
|
snprintf(buf, sizeof(buf), cname && cname[0] ? "#%s" : "#Ch%d", cname ? cname : "", ch);
|
|
labels.push_back(buf);
|
|
ids.push_back(encodeChannelId(ch));
|
|
LOG_DEBUG("messageViewModeMenu: Added live channel %s (id=%d)", buf, encodeChannelId(ch));
|
|
}
|
|
}
|
|
|
|
// Registry channels
|
|
for (int ch : graphics::MessageRenderer::getSeenChannels()) {
|
|
if (ch < 0 || ch >= 8)
|
|
continue;
|
|
auto msgs = messageStore.getChannelMessages((uint8_t)ch);
|
|
if (msgs.empty())
|
|
continue;
|
|
int enc = encodeChannelId(ch);
|
|
if (std::find(ids.begin(), ids.end(), enc) == ids.end()) {
|
|
char buf[40];
|
|
const char *cname = channels.getName(ch);
|
|
snprintf(buf, sizeof(buf), cname && cname[0] ? "#%s" : "#Ch%d", cname ? cname : "", ch);
|
|
labels.push_back(buf);
|
|
ids.push_back(enc);
|
|
LOG_DEBUG("messageViewModeMenu: Added registry channel %s (id=%d)", buf, enc);
|
|
}
|
|
}
|
|
|
|
// Gather unique peers
|
|
auto dms = messageStore.getDirectMessages();
|
|
std::vector<uint32_t> uniquePeers;
|
|
for (const auto &m : dms) {
|
|
uint32_t peer = (m.sender == nodeDB->getNodeNum()) ? m.dest : m.sender;
|
|
if (peer != nodeDB->getNodeNum() && std::find(uniquePeers.begin(), uniquePeers.end(), peer) == uniquePeers.end())
|
|
uniquePeers.push_back(peer);
|
|
}
|
|
for (uint32_t peer : graphics::MessageRenderer::getSeenPeers()) {
|
|
if (peer != nodeDB->getNodeNum() && std::find(uniquePeers.begin(), uniquePeers.end(), peer) == uniquePeers.end())
|
|
uniquePeers.push_back(peer);
|
|
}
|
|
std::sort(uniquePeers.begin(), uniquePeers.end());
|
|
|
|
// Encode peers
|
|
for (size_t i = 0; i < uniquePeers.size(); ++i) {
|
|
uint32_t peer = uniquePeers[i];
|
|
const auto *node = nodeDB->getMeshNode(peer);
|
|
std::string name;
|
|
if (nodeInfoLiteHasUser(node))
|
|
name = sanitizeString(node->long_name).substr(0, 15);
|
|
else {
|
|
char buf[20];
|
|
snprintf(buf, sizeof(buf), "Node %08X", peer);
|
|
name = buf;
|
|
}
|
|
labels.push_back("@" + name);
|
|
int encPeer = 1000 + (int)idToPeer.size();
|
|
ids.push_back(encPeer);
|
|
idToPeer.push_back(peer);
|
|
LOG_DEBUG("messageViewModeMenu: Added DM %s peer=0x%08x id=%d", name.c_str(), (unsigned int)peer, encPeer);
|
|
}
|
|
|
|
// Active ID
|
|
int activeId = -2;
|
|
auto mode = graphics::MessageRenderer::getThreadMode();
|
|
if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL)
|
|
activeId = encodeChannelId(graphics::MessageRenderer::getThreadChannel());
|
|
else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) {
|
|
uint32_t cur = graphics::MessageRenderer::getThreadPeer();
|
|
for (size_t i = 0; i < idToPeer.size(); ++i)
|
|
if (idToPeer[i] == cur) {
|
|
activeId = 1000 + (int)i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
LOG_DEBUG("messageViewModeMenu: Active thread id=%d", activeId);
|
|
|
|
// Build banner
|
|
static std::vector<const char *> options;
|
|
static std::vector<int> optionIds;
|
|
options.clear();
|
|
optionIds.clear();
|
|
|
|
int initialIndex = 0;
|
|
for (size_t i = 0; i < labels.size(); i++) {
|
|
options.push_back(labels[i].c_str());
|
|
optionIds.push_back(ids[i]);
|
|
if (ids[i] == activeId)
|
|
initialIndex = (int)i;
|
|
}
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Select Conversation";
|
|
bannerOptions.optionsArrayPtr = options.data();
|
|
bannerOptions.optionsEnumPtr = optionIds.data();
|
|
bannerOptions.optionsCount = options.size();
|
|
bannerOptions.InitialSelected = initialIndex;
|
|
|
|
bannerOptions.bannerCallback = [=](int selected) -> void {
|
|
LOG_DEBUG("messageViewModeMenu: selected=%d", selected);
|
|
if (selected == -1) {
|
|
menuHandler::menuQueue = menuHandler::MessageResponseMenu;
|
|
screen->runNow();
|
|
} else if (selected == -2) {
|
|
graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::ALL);
|
|
} else if (isChannelSel(selected)) {
|
|
int ch = selected - 100;
|
|
graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::CHANNEL, ch);
|
|
} else if (selected >= 1000) {
|
|
int idx = selected - 1000;
|
|
if (idx >= 0 && (size_t)idx < idToPeer.size()) {
|
|
uint32_t peer = idToPeer[idx];
|
|
graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::DIRECT, -1, peer);
|
|
}
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::homeBaseMenu()
|
|
{
|
|
enum optionsNumbers { Back, Mute, Backlight, Position, Preset, Freetext, Sleep, enumEnd };
|
|
|
|
static const char *optionsArray[enumEnd] = {"Back"};
|
|
static int optionsEnumArray[enumEnd] = {Back};
|
|
int options = 1;
|
|
|
|
if (moduleConfig.external_notification.enabled && externalNotificationModule &&
|
|
config.device.buzzer_mode != meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED) {
|
|
if (!externalNotificationModule->getMute()) {
|
|
optionsArray[options] = "Temporarily Mute";
|
|
} else {
|
|
optionsArray[options] = "Unmute";
|
|
}
|
|
optionsEnumArray[options++] = Mute;
|
|
}
|
|
#if defined(PIN_EINK_EN) || defined(PCA_PIN_EINK_EN)
|
|
optionsArray[options] = "Toggle Backlight";
|
|
optionsEnumArray[options++] = Backlight;
|
|
#else
|
|
optionsArray[options] = "Sleep Screen";
|
|
optionsEnumArray[options++] = Sleep;
|
|
#endif
|
|
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
|
|
optionsArray[options] = "Send Position";
|
|
} else {
|
|
optionsArray[options] = "Send Node Info";
|
|
}
|
|
optionsEnumArray[options++] = Position;
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Home Action";
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
bannerOptions.message = "Home";
|
|
}
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
|
bannerOptions.optionsCount = options;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == Mute) {
|
|
if (moduleConfig.external_notification.enabled && externalNotificationModule) {
|
|
externalNotificationModule->setMute(!externalNotificationModule->getMute());
|
|
IF_SCREEN(if (!externalNotificationModule->getMute()) externalNotificationModule->stopNow();)
|
|
}
|
|
} else if (selected == Backlight) {
|
|
screen->setOn(false);
|
|
#if defined(PIN_EINK_EN)
|
|
if (uiconfig.screen_brightness == 1) {
|
|
uiconfig.screen_brightness = 0;
|
|
digitalWrite(PIN_EINK_EN, LOW);
|
|
} else {
|
|
uiconfig.screen_brightness = 1;
|
|
digitalWrite(PIN_EINK_EN, HIGH);
|
|
}
|
|
saveUIConfig();
|
|
#elif defined(PCA_PIN_EINK_EN)
|
|
if (uiconfig.screen_brightness > 0) {
|
|
uiconfig.screen_brightness = 0;
|
|
io.digitalWrite(PCA_PIN_EINK_EN, LOW);
|
|
} else {
|
|
uiconfig.screen_brightness = 1;
|
|
io.digitalWrite(PCA_PIN_EINK_EN, HIGH);
|
|
}
|
|
saveUIConfig();
|
|
#endif
|
|
} else if (selected == Sleep) {
|
|
screen->setOn(false);
|
|
} else if (selected == Position) {
|
|
service->refreshLocalMeshNode();
|
|
if (service->trySendPosition(NODENUM_BROADCAST, true)) {
|
|
IF_SCREEN(screen->showSimpleBanner("Position\nSent", 3000));
|
|
} else {
|
|
IF_SCREEN(screen->showSimpleBanner("Node Info\nSent", 3000));
|
|
}
|
|
} else if (selected == Preset) {
|
|
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST);
|
|
} else if (selected == Freetext) {
|
|
cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST);
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::textMessageMenu()
|
|
{
|
|
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST);
|
|
}
|
|
|
|
void menuHandler::textMessageBaseMenu()
|
|
{
|
|
enum optionsNumbers { Back, Preset, Freetext, enumEnd };
|
|
|
|
static const char *optionsArray[enumEnd] = {"Back"};
|
|
static int optionsEnumArray[enumEnd] = {Back};
|
|
int options = 1;
|
|
optionsArray[options] = "New Preset Msg";
|
|
optionsEnumArray[options++] = Preset;
|
|
if (kb_found) {
|
|
optionsArray[options] = "New Freetext Msg";
|
|
optionsEnumArray[options++] = Freetext;
|
|
}
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Message Action";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
|
bannerOptions.optionsCount = options;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == Preset) {
|
|
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST);
|
|
} else if (selected == Freetext) {
|
|
cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST);
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::systemBaseMenu()
|
|
{
|
|
enum optionsNumbers { Back, Notifications, ScreenOptions, Bluetooth, WiFiToggle, PowerMenu, Test, enumEnd };
|
|
static const char *optionsArray[enumEnd] = {"Back"};
|
|
static int optionsEnumArray[enumEnd] = {Back};
|
|
int options = 1;
|
|
|
|
optionsArray[options] = "Notifications";
|
|
optionsEnumArray[options++] = Notifications;
|
|
|
|
optionsArray[options] = "Display Options";
|
|
optionsEnumArray[options++] = ScreenOptions;
|
|
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
optionsArray[options] = "Bluetooth";
|
|
} else {
|
|
optionsArray[options] = "Bluetooth Toggle";
|
|
}
|
|
optionsEnumArray[options++] = Bluetooth;
|
|
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
|
|
optionsArray[options] = "WiFi Toggle";
|
|
optionsEnumArray[options++] = WiFiToggle;
|
|
#endif
|
|
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
optionsArray[options] = "Power";
|
|
} else {
|
|
optionsArray[options] = "Reboot/Shutdown";
|
|
}
|
|
optionsEnumArray[options++] = PowerMenu;
|
|
|
|
if (test_enabled) {
|
|
optionsArray[options] = "Test Menu";
|
|
optionsEnumArray[options++] = Test;
|
|
}
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "System Action";
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
bannerOptions.message = "System";
|
|
}
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = options;
|
|
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == Notifications) {
|
|
menuHandler::menuQueue = menuHandler::BuzzerModeMenuPicker;
|
|
screen->runNow();
|
|
} else if (selected == ScreenOptions) {
|
|
menuHandler::menuQueue = menuHandler::ScreenOptionsMenu;
|
|
screen->runNow();
|
|
} else if (selected == PowerMenu) {
|
|
menuHandler::menuQueue = menuHandler::PowerMenu;
|
|
screen->runNow();
|
|
} else if (selected == Test) {
|
|
menuHandler::menuQueue = menuHandler::TestMenu;
|
|
screen->runNow();
|
|
} else if (selected == Bluetooth) {
|
|
menuQueue = BluetoothToggleMenu;
|
|
screen->runNow();
|
|
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
|
|
} else if (selected == WiFiToggle) {
|
|
menuQueue = WifiToggleMenu;
|
|
screen->runNow();
|
|
#endif
|
|
} else if (selected == Back && !test_enabled) {
|
|
test_count++;
|
|
if (test_count > 4) {
|
|
test_enabled = true;
|
|
}
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::favoriteBaseMenu()
|
|
{
|
|
enum optionsNumbers { Back, Preset, Freetext, GoToChat, Remove, TraceRoute, enumEnd };
|
|
|
|
static const char *optionsArray[enumEnd] = {"Back"};
|
|
static int optionsEnumArray[enumEnd] = {Back};
|
|
int options = 1;
|
|
|
|
// Only show "View Conversation" if a message exists with this node
|
|
uint32_t peer = graphics::UIRenderer::currentFavoriteNodeNum;
|
|
bool hasConversation = false;
|
|
for (const auto &m : messageStore.getMessages()) {
|
|
if ((m.sender == peer || m.dest == peer)) {
|
|
hasConversation = true;
|
|
break;
|
|
}
|
|
}
|
|
if (hasConversation) {
|
|
optionsArray[options] = "Go To Chat";
|
|
optionsEnumArray[options++] = GoToChat;
|
|
}
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
optionsArray[options] = "New Preset";
|
|
} else {
|
|
optionsArray[options] = "New Preset Msg";
|
|
}
|
|
optionsEnumArray[options++] = Preset;
|
|
|
|
if (kb_found) {
|
|
optionsArray[options] = "New Freetext Msg";
|
|
optionsEnumArray[options++] = Freetext;
|
|
}
|
|
|
|
if (currentResolution != ScreenResolution::UltraLow) {
|
|
optionsArray[options] = "Trace Route";
|
|
optionsEnumArray[options++] = TraceRoute;
|
|
}
|
|
optionsArray[options] = "Remove Favorite";
|
|
optionsEnumArray[options++] = Remove;
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Favorites Action";
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
bannerOptions.message = "Favorites";
|
|
}
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
|
bannerOptions.optionsCount = options;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == Preset) {
|
|
cannedMessageModule->LaunchWithDestination(graphics::UIRenderer::currentFavoriteNodeNum);
|
|
} else if (selected == Freetext) {
|
|
cannedMessageModule->LaunchFreetextWithDestination(graphics::UIRenderer::currentFavoriteNodeNum);
|
|
}
|
|
// Handle new Go To Thread action
|
|
else if (selected == GoToChat) {
|
|
// Switch thread to direct conversation with this node
|
|
graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::DIRECT, -1,
|
|
graphics::UIRenderer::currentFavoriteNodeNum);
|
|
|
|
// Manually create and send a UIFrameEvent to trigger the jump
|
|
UIFrameEvent evt;
|
|
evt.action = UIFrameEvent::Action::SWITCH_TO_TEXTMESSAGE;
|
|
screen->handleUIFrameEvent(&evt);
|
|
} else if (selected == Remove) {
|
|
menuHandler::menuQueue = menuHandler::RemoveFavorite;
|
|
screen->runNow();
|
|
} else if (selected == TraceRoute) {
|
|
if (traceRouteModule) {
|
|
traceRouteModule->launch(graphics::UIRenderer::currentFavoriteNodeNum);
|
|
}
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::positionBaseMenu()
|
|
{
|
|
enum class PositionAction {
|
|
GpsToggle,
|
|
GpsFormat,
|
|
CompassMenu,
|
|
CompassCalibrate,
|
|
GPSSmartPosition,
|
|
GPSUpdateInterval,
|
|
GPSPositionBroadcast
|
|
};
|
|
|
|
static const PositionMenuOption baseOptions[] = {
|
|
{"Back", OptionsAction::Back},
|
|
{"On/Off Toggle", OptionsAction::Select, static_cast<int>(PositionAction::GpsToggle)},
|
|
{"Format", OptionsAction::Select, static_cast<int>(PositionAction::GpsFormat)},
|
|
{"Smart Position", OptionsAction::Select, static_cast<int>(PositionAction::GPSSmartPosition)},
|
|
{"Update Interval", OptionsAction::Select, static_cast<int>(PositionAction::GPSUpdateInterval)},
|
|
{"Broadcast Interval", OptionsAction::Select, static_cast<int>(PositionAction::GPSPositionBroadcast)},
|
|
{"Compass", OptionsAction::Select, static_cast<int>(PositionAction::CompassMenu)},
|
|
};
|
|
|
|
static const PositionMenuOption calibrateOptions[] = {
|
|
{"Back", OptionsAction::Back},
|
|
{"On/Off Toggle", OptionsAction::Select, static_cast<int>(PositionAction::GpsToggle)},
|
|
{"Format", OptionsAction::Select, static_cast<int>(PositionAction::GpsFormat)},
|
|
{"Smart Position", OptionsAction::Select, static_cast<int>(PositionAction::GPSSmartPosition)},
|
|
{"Update Interval", OptionsAction::Select, static_cast<int>(PositionAction::GPSUpdateInterval)},
|
|
{"Broadcast Interval", OptionsAction::Select, static_cast<int>(PositionAction::GPSPositionBroadcast)},
|
|
{"Compass", OptionsAction::Select, static_cast<int>(PositionAction::CompassMenu)},
|
|
{"Compass Calibrate", OptionsAction::Select, static_cast<int>(PositionAction::CompassCalibrate)},
|
|
};
|
|
|
|
constexpr size_t baseCount = sizeof(baseOptions) / sizeof(baseOptions[0]);
|
|
static std::array<const char *, baseCount> baseLabels{};
|
|
#if !MESHTASTIC_EXCLUDE_ACCELEROMETER
|
|
constexpr size_t calibrateCount = sizeof(calibrateOptions) / sizeof(calibrateOptions[0]);
|
|
static std::array<const char *, calibrateCount> calibrateLabels{};
|
|
#endif
|
|
|
|
auto onSelection = [](const PositionMenuOption &option, int) -> void {
|
|
if (option.action == OptionsAction::Back) {
|
|
return;
|
|
}
|
|
|
|
if (!option.hasValue) {
|
|
return;
|
|
}
|
|
|
|
auto action = static_cast<PositionAction>(option.value);
|
|
switch (action) {
|
|
case PositionAction::GpsToggle:
|
|
menuQueue = GpsToggleMenu;
|
|
screen->runNow();
|
|
break;
|
|
case PositionAction::GpsFormat:
|
|
menuQueue = GpsFormatMenu;
|
|
screen->runNow();
|
|
break;
|
|
case PositionAction::CompassMenu:
|
|
menuQueue = CompassPointNorthMenu;
|
|
screen->runNow();
|
|
break;
|
|
case PositionAction::CompassCalibrate:
|
|
#if !MESHTASTIC_EXCLUDE_ACCELEROMETER
|
|
if (accelerometerThread) {
|
|
accelerometerThread->calibrate(30);
|
|
}
|
|
#endif
|
|
#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && !MESHTASTIC_EXCLUDE_MAGNETOMETER
|
|
if (magnetometerThread) {
|
|
magnetometerThread->calibrate(30);
|
|
}
|
|
#endif
|
|
break;
|
|
case PositionAction::GPSSmartPosition:
|
|
menuQueue = GpsSmartPositionMenu;
|
|
screen->runNow();
|
|
break;
|
|
case PositionAction::GPSUpdateInterval:
|
|
menuQueue = GpsUpdateIntervalMenu;
|
|
screen->runNow();
|
|
break;
|
|
case PositionAction::GPSPositionBroadcast:
|
|
menuQueue = GpsPositionBroadcastMenu;
|
|
screen->runNow();
|
|
break;
|
|
}
|
|
};
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
#if !MESHTASTIC_EXCLUDE_ACCELEROMETER
|
|
if (accelerometerThread) {
|
|
bannerOptions = createStaticBannerOptions("GPS Action", calibrateOptions, calibrateLabels, onSelection);
|
|
} else {
|
|
bannerOptions = createStaticBannerOptions("GPS Action", baseOptions, baseLabels, onSelection);
|
|
}
|
|
#else
|
|
bannerOptions = createStaticBannerOptions("GPS Action", baseOptions, baseLabels, onSelection);
|
|
#endif
|
|
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::nodeListMenu()
|
|
{
|
|
enum optionsNumbers { Back, NodePicker, TraceRoute, Verify, Reset, NodeNameLength, enumEnd };
|
|
static const char *optionsArray[enumEnd] = {"Back"};
|
|
static int optionsEnumArray[enumEnd] = {Back};
|
|
int options = 1;
|
|
|
|
optionsArray[options] = "Node Actions / Settings";
|
|
optionsEnumArray[options++] = NodePicker;
|
|
|
|
if (currentResolution != ScreenResolution::UltraLow) {
|
|
optionsArray[options] = "Show Long/Short Name";
|
|
optionsEnumArray[options++] = NodeNameLength;
|
|
}
|
|
optionsArray[options] = "Reset NodeDB";
|
|
optionsEnumArray[options++] = Reset;
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Node Action";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = options;
|
|
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == NodePicker) {
|
|
menuQueue = NodePickerMenu;
|
|
screen->runNow();
|
|
} else if (selected == Reset) {
|
|
menuQueue = ResetNodeDbMenu;
|
|
screen->runNow();
|
|
} else if (selected == NodeNameLength) {
|
|
menuHandler::menuQueue = menuHandler::NodeNameLengthMenu;
|
|
screen->runNow();
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::NodePicker()
|
|
{
|
|
const char *NODE_PICKER_TITLE;
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
NODE_PICKER_TITLE = "Pick Node";
|
|
} else {
|
|
NODE_PICKER_TITLE = "Pick A Node";
|
|
}
|
|
screen->showNodePicker(NODE_PICKER_TITLE, 30000, [](uint32_t nodenum) -> void {
|
|
LOG_INFO("Nodenum: %u", nodenum);
|
|
// Store the selection so the Manage Node menu knows which node to operate on
|
|
menuHandler::pickedNodeNum = nodenum;
|
|
// Keep UI favorite context in sync (used elsewhere for some node-based actions)
|
|
graphics::UIRenderer::currentFavoriteNodeNum = nodenum;
|
|
menuQueue = ManageNodeMenu;
|
|
screen->runNow();
|
|
});
|
|
}
|
|
|
|
void menuHandler::manageNodeMenu()
|
|
{
|
|
// If we don't have a node selected yet, go fast exit
|
|
auto node = nodeDB->getMeshNode(menuHandler::pickedNodeNum);
|
|
if (!node) {
|
|
return;
|
|
}
|
|
enum optionsNumbers { Back, Favorite, Mute, TraceRoute, KeyVerification, Ignore, enumEnd };
|
|
static const char *optionsArray[enumEnd] = {"Back"};
|
|
static int optionsEnumArray[enumEnd] = {Back};
|
|
int options = 1;
|
|
|
|
if (nodeInfoLiteIsFavorite(node)) {
|
|
optionsArray[options] = "Unfavorite";
|
|
} else {
|
|
optionsArray[options] = "Favorite";
|
|
}
|
|
optionsEnumArray[options++] = Favorite;
|
|
|
|
bool isMuted = nodeInfoLiteIsMuted(node);
|
|
if (isMuted) {
|
|
optionsArray[options] = "Unmute Notifications";
|
|
} else {
|
|
optionsArray[options] = "Mute Notifications";
|
|
}
|
|
optionsEnumArray[options++] = Mute;
|
|
|
|
optionsArray[options] = "Trace Route";
|
|
optionsEnumArray[options++] = TraceRoute;
|
|
|
|
optionsArray[options] = "Key Verification";
|
|
optionsEnumArray[options++] = KeyVerification;
|
|
|
|
if (nodeInfoLiteIsIgnored(node)) {
|
|
optionsArray[options] = "Unignore Node";
|
|
} else {
|
|
optionsArray[options] = "Ignore Node";
|
|
}
|
|
optionsEnumArray[options++] = Ignore;
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
|
|
std::string title = "";
|
|
if (nodeInfoLiteHasUser(node) && node->long_name[0]) {
|
|
title += sanitizeString(node->long_name).substr(0, 15);
|
|
} else {
|
|
char buf[20];
|
|
snprintf(buf, sizeof(buf), "%08X", (unsigned int)node->num);
|
|
title += buf;
|
|
}
|
|
bannerOptions.message = title.c_str();
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = options;
|
|
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == Back) {
|
|
menuQueue = NodeBaseMenu;
|
|
screen->runNow();
|
|
return;
|
|
}
|
|
|
|
if (selected == Favorite) {
|
|
const auto *n = nodeDB->getMeshNode(menuHandler::pickedNodeNum);
|
|
if (!n) {
|
|
return;
|
|
}
|
|
if (nodeInfoLiteIsFavorite(n)) {
|
|
LOG_INFO("Removing node %08X from favorites", menuHandler::pickedNodeNum);
|
|
nodeDB->set_favorite(false, menuHandler::pickedNodeNum);
|
|
} else {
|
|
LOG_INFO("Adding node %08X to favorites", menuHandler::pickedNodeNum);
|
|
// set_favorite() already logs PROTECTED_CAP_WARN_FMT on a cap refusal; don't double-log here.
|
|
nodeDB->set_favorite(true, menuHandler::pickedNodeNum);
|
|
}
|
|
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
|
|
return;
|
|
}
|
|
|
|
if (selected == Mute) {
|
|
auto n = nodeDB->getMeshNode(menuHandler::pickedNodeNum);
|
|
if (!n) {
|
|
return;
|
|
}
|
|
|
|
const bool wasMuted = nodeInfoLiteIsMuted(n);
|
|
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_MUTED_MASK, !wasMuted);
|
|
LOG_INFO(wasMuted ? "Unmuted node %08X" : "Muted node %08X", menuHandler::pickedNodeNum);
|
|
nodeDB->notifyObservers(true);
|
|
nodeDB->saveToDisk();
|
|
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
|
|
return;
|
|
}
|
|
|
|
if (selected == TraceRoute) {
|
|
LOG_INFO("Starting traceroute to %08X", menuHandler::pickedNodeNum);
|
|
if (traceRouteModule) {
|
|
traceRouteModule->startTraceRoute(menuHandler::pickedNodeNum);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (selected == KeyVerification) {
|
|
LOG_INFO("Initiating key verification with %08X", menuHandler::pickedNodeNum);
|
|
if (keyVerificationModule) {
|
|
keyVerificationModule->sendInitialRequest(menuHandler::pickedNodeNum);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (selected == Ignore) {
|
|
auto n = nodeDB->getMeshNode(menuHandler::pickedNodeNum);
|
|
if (!n) {
|
|
return;
|
|
}
|
|
|
|
bool changed = false;
|
|
if (nodeInfoLiteIsIgnored(n)) {
|
|
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, false);
|
|
LOG_INFO("Unignoring node %08X", menuHandler::pickedNodeNum);
|
|
changed = true;
|
|
} else if (nodeDB->setProtectedFlag(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)) {
|
|
LOG_INFO("Ignoring node %08X", menuHandler::pickedNodeNum);
|
|
changed = true;
|
|
} else {
|
|
LOG_WARN(NodeDB::PROTECTED_CAP_WARN_FMT, "ignore", menuHandler::pickedNodeNum, MAX_NUM_NODES - 2);
|
|
}
|
|
// Only persist/notify when the ignore bit actually moved; a cap
|
|
// refusal changed nothing and shouldn't trigger a prefs save.
|
|
if (changed) {
|
|
nodeDB->notifyObservers(true);
|
|
nodeDB->saveToDisk();
|
|
}
|
|
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
|
|
return;
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::nodeNameLengthMenu()
|
|
{
|
|
static const NodeNameOption nodeNameOptions[] = {
|
|
{"Back", OptionsAction::Back},
|
|
{"Long", OptionsAction::Select, true},
|
|
{"Short", OptionsAction::Select, false},
|
|
};
|
|
|
|
constexpr size_t nodeNameCount = sizeof(nodeNameOptions) / sizeof(nodeNameOptions[0]);
|
|
static std::array<const char *, nodeNameCount> nodeNameLabels{};
|
|
|
|
auto bannerOptions = createStaticBannerOptions("Node Name Length", nodeNameOptions, nodeNameLabels,
|
|
[](const NodeNameOption &option, int) -> void {
|
|
if (option.action == OptionsAction::Back) {
|
|
menuQueue = NodeBaseMenu;
|
|
screen->runNow();
|
|
return;
|
|
}
|
|
|
|
if (!option.hasValue) {
|
|
return;
|
|
}
|
|
|
|
if (config.display.use_long_node_name == option.value) {
|
|
return;
|
|
}
|
|
|
|
config.display.use_long_node_name = option.value;
|
|
saveUIConfig();
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
LOG_INFO("Setting names to %s", option.value ? "long" : "short");
|
|
});
|
|
|
|
int initialSelection = config.display.use_long_node_name ? 1 : 2;
|
|
bannerOptions.InitialSelected = initialSelection;
|
|
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::resetNodeDBMenu()
|
|
{
|
|
static const char *optionsArray[] = {"Back", "Reset All", "Preserve Favorites"};
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Confirm Reset NodeDB";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 3;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == 1 || selected == 2) {
|
|
screen->setFrames(Screen::FOCUS_DEFAULT);
|
|
}
|
|
if (selected == 1) {
|
|
LOG_INFO("Initiate node-db reset");
|
|
nodeDB->resetNodes();
|
|
disableBluetooth();
|
|
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
|
} else if (selected == 2) {
|
|
LOG_INFO("Initiate node-db reset but keeping favorites");
|
|
nodeDB->resetNodes(1);
|
|
disableBluetooth();
|
|
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
|
} else if (selected == 0) {
|
|
menuQueue = NodeBaseMenu;
|
|
screen->runNow();
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::compassNorthMenu()
|
|
{
|
|
static const CompassOption compassOptions[] = {
|
|
{"Back", OptionsAction::Back},
|
|
{"Dynamic", OptionsAction::Select, meshtastic_CompassMode_DYNAMIC},
|
|
{"Fixed Ring", OptionsAction::Select, meshtastic_CompassMode_FIXED_RING},
|
|
{"Freeze Heading", OptionsAction::Select, meshtastic_CompassMode_FREEZE_HEADING},
|
|
};
|
|
|
|
constexpr size_t compassCount = sizeof(compassOptions) / sizeof(compassOptions[0]);
|
|
static std::array<const char *, compassCount> compassLabels{};
|
|
|
|
auto bannerOptions = createStaticBannerOptions("North Directions?", compassOptions, compassLabels,
|
|
[](const CompassOption &option, int) -> void {
|
|
if (option.action == OptionsAction::Back) {
|
|
menuQueue = PositionBaseMenu;
|
|
screen->runNow();
|
|
return;
|
|
}
|
|
|
|
if (!option.hasValue) {
|
|
return;
|
|
}
|
|
|
|
if (uiconfig.compass_mode == option.value) {
|
|
return;
|
|
}
|
|
|
|
uiconfig.compass_mode = option.value;
|
|
saveUIConfig();
|
|
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
|
|
});
|
|
|
|
int initialSelection = 0;
|
|
for (size_t i = 0; i < compassCount; ++i) {
|
|
if (compassOptions[i].hasValue && uiconfig.compass_mode == compassOptions[i].value) {
|
|
initialSelection = static_cast<int>(i);
|
|
break;
|
|
}
|
|
}
|
|
bannerOptions.InitialSelected = initialSelection;
|
|
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
#if !MESHTASTIC_EXCLUDE_GPS
|
|
void menuHandler::GPSToggleMenu()
|
|
{
|
|
static const GPSToggleOption gpsToggleOptions[] = {
|
|
{"Back", OptionsAction::Back},
|
|
{"Enabled", OptionsAction::Select, meshtastic_Config_PositionConfig_GpsMode_ENABLED},
|
|
{"Disabled", OptionsAction::Select, meshtastic_Config_PositionConfig_GpsMode_DISABLED},
|
|
};
|
|
|
|
constexpr size_t toggleCount = sizeof(gpsToggleOptions) / sizeof(gpsToggleOptions[0]);
|
|
static std::array<const char *, toggleCount> toggleLabels{};
|
|
|
|
auto bannerOptions =
|
|
createStaticBannerOptions("Toggle GPS", gpsToggleOptions, toggleLabels, [](const GPSToggleOption &option, int) -> void {
|
|
if (option.action == OptionsAction::Back) {
|
|
menuQueue = PositionBaseMenu;
|
|
screen->runNow();
|
|
return;
|
|
}
|
|
|
|
if (!option.hasValue) {
|
|
return;
|
|
}
|
|
|
|
if (config.position.gps_mode == option.value) {
|
|
return;
|
|
}
|
|
|
|
config.position.gps_mode = option.value;
|
|
if (option.value == meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
|
|
playGPSEnableBeep();
|
|
gps->enable();
|
|
} else {
|
|
playGPSDisableBeep();
|
|
gps->disable();
|
|
}
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
});
|
|
|
|
int initialSelection = 0;
|
|
for (size_t i = 0; i < toggleCount; ++i) {
|
|
if (gpsToggleOptions[i].hasValue && config.position.gps_mode == gpsToggleOptions[i].value) {
|
|
initialSelection = static_cast<int>(i);
|
|
break;
|
|
}
|
|
}
|
|
bannerOptions.InitialSelected = initialSelection;
|
|
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
void menuHandler::GPSFormatMenu()
|
|
{
|
|
static const GPSFormatOption formatOptionsHigh[] = {
|
|
{"Back", OptionsAction::Back},
|
|
{"Decimal Degrees", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_DEC},
|
|
{"Degrees Minutes Seconds", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_DMS},
|
|
{"Universal Transverse Mercator", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_UTM},
|
|
{"Military Grid Reference System", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_MGRS},
|
|
{"Open Location Code", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_OLC},
|
|
{"Ordnance Survey Grid Ref", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_OSGR},
|
|
{"Maidenhead Locator", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_MLS},
|
|
};
|
|
|
|
static const GPSFormatOption formatOptionsLow[] = {
|
|
{"Back", OptionsAction::Back},
|
|
{"DEC", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_DEC},
|
|
{"DMS", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_DMS},
|
|
{"UTM", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_UTM},
|
|
{"MGRS", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_MGRS},
|
|
{"OLC", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_OLC},
|
|
{"OSGR", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_OSGR},
|
|
{"MLS", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_MLS},
|
|
};
|
|
|
|
constexpr size_t formatCount = sizeof(formatOptionsHigh) / sizeof(formatOptionsHigh[0]);
|
|
static std::array<const char *, formatCount> formatLabelsHigh{};
|
|
static std::array<const char *, formatCount> formatLabelsLow{};
|
|
|
|
auto onSelection = [](const GPSFormatOption &option, int) -> void {
|
|
if (option.action == OptionsAction::Back) {
|
|
menuQueue = PositionBaseMenu;
|
|
screen->runNow();
|
|
return;
|
|
}
|
|
|
|
if (!option.hasValue) {
|
|
return;
|
|
}
|
|
|
|
if (uiconfig.gps_format == option.value) {
|
|
return;
|
|
}
|
|
|
|
uiconfig.gps_format = option.value;
|
|
saveUIConfig();
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
};
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
int initialSelection = 0;
|
|
|
|
if (currentResolution == ScreenResolution::High) {
|
|
bannerOptions = createStaticBannerOptions("GPS Format", formatOptionsHigh, formatLabelsHigh, onSelection);
|
|
for (size_t i = 0; i < formatCount; ++i) {
|
|
if (formatOptionsHigh[i].hasValue && uiconfig.gps_format == formatOptionsHigh[i].value) {
|
|
initialSelection = static_cast<int>(i);
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
bannerOptions = createStaticBannerOptions("GPS Format", formatOptionsLow, formatLabelsLow, onSelection);
|
|
for (size_t i = 0; i < formatCount; ++i) {
|
|
if (formatOptionsLow[i].hasValue && uiconfig.gps_format == formatOptionsLow[i].value) {
|
|
initialSelection = static_cast<int>(i);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
bannerOptions.InitialSelected = initialSelection;
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::GPSSmartPositionMenu()
|
|
{
|
|
static const char *optionsArray[] = {"Back", "Enabled", "Disabled"};
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Toggle Smart Position";
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
bannerOptions.message = "Smrt Postn";
|
|
}
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 3;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == 0) {
|
|
menuQueue = PositionBaseMenu;
|
|
screen->runNow();
|
|
} else if (selected == 1) {
|
|
config.position.position_broadcast_smart_enabled = true;
|
|
saveUIConfig();
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
|
} else if (selected == 2) {
|
|
config.position.position_broadcast_smart_enabled = false;
|
|
saveUIConfig();
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
|
}
|
|
};
|
|
bannerOptions.InitialSelected = config.position.position_broadcast_smart_enabled ? 1 : 2;
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::GPSUpdateIntervalMenu()
|
|
{
|
|
static const char *optionsArray[] = {"Back", "8 seconds", "20 seconds", "40 seconds", "1 minute", "80 seconds",
|
|
"2 minutes", "5 minutes", "10 minutes", "15 minutes", "30 minutes", "1 hour",
|
|
"6 hours", "12 hours", "24 hours", "At Boot Only"};
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Update Interval";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 16;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == 0) {
|
|
menuQueue = PositionBaseMenu;
|
|
screen->runNow();
|
|
} else if (selected == 1) {
|
|
config.position.gps_update_interval = 8;
|
|
} else if (selected == 2) {
|
|
config.position.gps_update_interval = 20;
|
|
} else if (selected == 3) {
|
|
config.position.gps_update_interval = 40;
|
|
} else if (selected == 4) {
|
|
config.position.gps_update_interval = 60;
|
|
} else if (selected == 5) {
|
|
config.position.gps_update_interval = 80;
|
|
} else if (selected == 6) {
|
|
config.position.gps_update_interval = 120;
|
|
} else if (selected == 7) {
|
|
config.position.gps_update_interval = 300;
|
|
} else if (selected == 8) {
|
|
config.position.gps_update_interval = 600;
|
|
} else if (selected == 9) {
|
|
config.position.gps_update_interval = 900;
|
|
} else if (selected == 10) {
|
|
config.position.gps_update_interval = 1800;
|
|
} else if (selected == 11) {
|
|
config.position.gps_update_interval = 3600;
|
|
} else if (selected == 12) {
|
|
config.position.gps_update_interval = 21600;
|
|
} else if (selected == 13) {
|
|
config.position.gps_update_interval = 43200;
|
|
} else if (selected == 14) {
|
|
config.position.gps_update_interval = 86400;
|
|
} else if (selected == 15) {
|
|
config.position.gps_update_interval = 2147483647; // At Boot Only
|
|
}
|
|
|
|
if (selected != 0) {
|
|
saveUIConfig();
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
|
}
|
|
};
|
|
|
|
if (config.position.gps_update_interval == 8) {
|
|
bannerOptions.InitialSelected = 1;
|
|
} else if (config.position.gps_update_interval == 20) {
|
|
bannerOptions.InitialSelected = 2;
|
|
} else if (config.position.gps_update_interval == 40) {
|
|
bannerOptions.InitialSelected = 3;
|
|
} else if (config.position.gps_update_interval == 60) {
|
|
bannerOptions.InitialSelected = 4;
|
|
} else if (config.position.gps_update_interval == 80) {
|
|
bannerOptions.InitialSelected = 5;
|
|
} else if (config.position.gps_update_interval == 120) {
|
|
bannerOptions.InitialSelected = 6;
|
|
} else if (config.position.gps_update_interval == 300) {
|
|
bannerOptions.InitialSelected = 7;
|
|
} else if (config.position.gps_update_interval == 600) {
|
|
bannerOptions.InitialSelected = 8;
|
|
} else if (config.position.gps_update_interval == 900) {
|
|
bannerOptions.InitialSelected = 9;
|
|
} else if (config.position.gps_update_interval == 1800) {
|
|
bannerOptions.InitialSelected = 10;
|
|
} else if (config.position.gps_update_interval == 3600) {
|
|
bannerOptions.InitialSelected = 11;
|
|
} else if (config.position.gps_update_interval == 21600) {
|
|
bannerOptions.InitialSelected = 12;
|
|
} else if (config.position.gps_update_interval == 43200) {
|
|
bannerOptions.InitialSelected = 13;
|
|
} else if (config.position.gps_update_interval == 86400) {
|
|
bannerOptions.InitialSelected = 14;
|
|
} else if (config.position.gps_update_interval == 2147483647) { // At Boot Only
|
|
bannerOptions.InitialSelected = 15;
|
|
} else {
|
|
bannerOptions.InitialSelected = 0;
|
|
}
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::GPSPositionBroadcastMenu()
|
|
{
|
|
static const char *optionsArray[] = {"Back", "1 minute", "90 seconds", "5 minutes", "15 minutes", "1 hour",
|
|
"2 hours", "3 hours", "4 hours", "5 hours", "6 hours", "12 hours",
|
|
"18 hours", "24 hours", "36 hours", "48 hours", "72 hours"};
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Broadcast Interval";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 17;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == 0) {
|
|
menuQueue = PositionBaseMenu;
|
|
screen->runNow();
|
|
} else if (selected == 1) {
|
|
config.position.position_broadcast_secs = 60;
|
|
} else if (selected == 2) {
|
|
config.position.position_broadcast_secs = 90;
|
|
} else if (selected == 3) {
|
|
config.position.position_broadcast_secs = 300;
|
|
} else if (selected == 4) {
|
|
config.position.position_broadcast_secs = 900;
|
|
} else if (selected == 5) {
|
|
config.position.position_broadcast_secs = 3600;
|
|
} else if (selected == 6) {
|
|
config.position.position_broadcast_secs = 7200;
|
|
} else if (selected == 7) {
|
|
config.position.position_broadcast_secs = 10800;
|
|
} else if (selected == 8) {
|
|
config.position.position_broadcast_secs = 14400;
|
|
} else if (selected == 9) {
|
|
config.position.position_broadcast_secs = 18000;
|
|
} else if (selected == 10) {
|
|
config.position.position_broadcast_secs = 21600;
|
|
} else if (selected == 11) {
|
|
config.position.position_broadcast_secs = 43200;
|
|
} else if (selected == 12) {
|
|
config.position.position_broadcast_secs = 64800;
|
|
} else if (selected == 13) {
|
|
config.position.position_broadcast_secs = 86400;
|
|
} else if (selected == 14) {
|
|
config.position.position_broadcast_secs = 129600;
|
|
} else if (selected == 15) {
|
|
config.position.position_broadcast_secs = 172800;
|
|
} else if (selected == 16) {
|
|
config.position.position_broadcast_secs = 259200;
|
|
}
|
|
|
|
if (selected != 0) {
|
|
saveUIConfig();
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
|
}
|
|
};
|
|
|
|
if (config.position.position_broadcast_secs == 60) {
|
|
bannerOptions.InitialSelected = 1;
|
|
} else if (config.position.position_broadcast_secs == 90) {
|
|
bannerOptions.InitialSelected = 2;
|
|
} else if (config.position.position_broadcast_secs == 300) {
|
|
bannerOptions.InitialSelected = 3;
|
|
} else if (config.position.position_broadcast_secs == 900) {
|
|
bannerOptions.InitialSelected = 4;
|
|
} else if (config.position.position_broadcast_secs == 3600) {
|
|
bannerOptions.InitialSelected = 5;
|
|
} else if (config.position.position_broadcast_secs == 7200) {
|
|
bannerOptions.InitialSelected = 6;
|
|
} else if (config.position.position_broadcast_secs == 10800) {
|
|
bannerOptions.InitialSelected = 7;
|
|
} else if (config.position.position_broadcast_secs == 14400) {
|
|
bannerOptions.InitialSelected = 8;
|
|
} else if (config.position.position_broadcast_secs == 18000) {
|
|
bannerOptions.InitialSelected = 9;
|
|
} else if (config.position.position_broadcast_secs == 21600) {
|
|
bannerOptions.InitialSelected = 10;
|
|
} else if (config.position.position_broadcast_secs == 43200) {
|
|
bannerOptions.InitialSelected = 11;
|
|
} else if (config.position.position_broadcast_secs == 64800) {
|
|
bannerOptions.InitialSelected = 12;
|
|
} else if (config.position.position_broadcast_secs == 86400) {
|
|
bannerOptions.InitialSelected = 13;
|
|
} else if (config.position.position_broadcast_secs == 129600) {
|
|
bannerOptions.InitialSelected = 14;
|
|
} else if (config.position.position_broadcast_secs == 172800) {
|
|
bannerOptions.InitialSelected = 15;
|
|
} else if (config.position.position_broadcast_secs == 259200) {
|
|
bannerOptions.InitialSelected = 16;
|
|
} else {
|
|
bannerOptions.InitialSelected = 0;
|
|
}
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
#endif
|
|
|
|
void menuHandler::bluetoothToggleMenu()
|
|
{
|
|
static const char *optionsArray[] = {"Back", "Enabled", "Disabled"};
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Toggle Bluetooth";
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
bannerOptions.message = "Bluetooth";
|
|
}
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 3;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == 0)
|
|
return;
|
|
else if (selected != (config.bluetooth.enabled ? 1 : 2)) {
|
|
InputEvent event = {.inputEvent = (input_broker_event)170, .kbchar = 170, .touchX = 0, .touchY = 0};
|
|
inputBroker->injectInputEvent(&event);
|
|
}
|
|
};
|
|
bannerOptions.InitialSelected = config.bluetooth.enabled ? 1 : 2;
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::BuzzerModeMenu()
|
|
{
|
|
static const char *optionsArray[] = {"All Enabled", "All Disabled", "Notifications", "System Only", "DMs Only"};
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Notification Sounds";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 5;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
config.device.buzzer_mode = (meshtastic_Config_DeviceConfig_BuzzerMode)selected;
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
};
|
|
bannerOptions.InitialSelected = config.device.buzzer_mode;
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::BrightnessPickerMenu()
|
|
{
|
|
static const char *optionsArray[] = {"Back", "Low", "Medium", "High"};
|
|
|
|
// Get current brightness level to set initial selection
|
|
int currentSelection = 1; // Default to Medium
|
|
if (uiconfig.screen_brightness >= 255) {
|
|
currentSelection = 3; // Very High
|
|
} else if (uiconfig.screen_brightness >= 128) {
|
|
currentSelection = 2; // High
|
|
} else {
|
|
currentSelection = 1; // Medium
|
|
}
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Brightness";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 4;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == 1) { // Medium
|
|
uiconfig.screen_brightness = 64;
|
|
} else if (selected == 2) { // High
|
|
uiconfig.screen_brightness = 128;
|
|
} else if (selected == 3) { // Very High
|
|
uiconfig.screen_brightness = 255;
|
|
}
|
|
|
|
if (selected != 0) { // Not "Back"
|
|
// Apply brightness immediately
|
|
#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190)
|
|
// For HELTEC devices, use analogWrite to control backlight
|
|
analogWrite(VTFT_LEDA, uiconfig.screen_brightness);
|
|
#elif defined(ST7789_CS) || defined(ST7796_CS)
|
|
static_cast<TFTDisplay *>(screen->getDisplayDevice())->setDisplayBrightness(uiconfig.screen_brightness);
|
|
#elif defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107)
|
|
screen->getDisplayDevice()->setBrightness(uiconfig.screen_brightness);
|
|
#endif
|
|
|
|
// Save to device
|
|
saveUIConfig();
|
|
|
|
LOG_INFO("Screen brightness set to %d", uiconfig.screen_brightness);
|
|
}
|
|
};
|
|
bannerOptions.InitialSelected = currentSelection;
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::switchToMUIMenu()
|
|
{
|
|
static const char *optionsArray[] = {"No", "Yes"};
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Switch to MUI?";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 2;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == 1) {
|
|
config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_COLOR;
|
|
config.bluetooth.enabled = false;
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::rebootMenu()
|
|
{
|
|
static const char *optionsArray[] = {"Back", "Confirm"};
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Reboot Device?";
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
bannerOptions.message = "Reboot";
|
|
}
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 2;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == 1) {
|
|
IF_SCREEN(screen->showSimpleBanner("Rebooting...", 0));
|
|
nodeDB->saveToDisk();
|
|
messageStore.saveToFlash();
|
|
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
|
} else {
|
|
menuQueue = PowerMenu;
|
|
screen->runNow();
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::shutdownMenu()
|
|
{
|
|
static const char *optionsArray[] = {"Back", "Confirm"};
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Shutdown Device?";
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
bannerOptions.message = "Shutdown";
|
|
}
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 2;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == 1) {
|
|
InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_SHUTDOWN, .kbchar = 0, .touchX = 0, .touchY = 0};
|
|
inputBroker->injectInputEvent(&event);
|
|
} else {
|
|
menuQueue = PowerMenu;
|
|
screen->runNow();
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::removeFavoriteMenu()
|
|
{
|
|
|
|
static const char *optionsArray[] = {"Back", "Yes"};
|
|
BannerOverlayOptions bannerOptions;
|
|
std::string message = "Unfavorite This Node?\n";
|
|
const auto *node = nodeDB->getMeshNode(graphics::UIRenderer::currentFavoriteNodeNum);
|
|
if (nodeInfoLiteHasUser(node)) {
|
|
message += sanitizeString(node->long_name).substr(0, 15);
|
|
}
|
|
bannerOptions.message = message.c_str();
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 2;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == 1) {
|
|
LOG_INFO("Removing %x as favorite node", graphics::UIRenderer::currentFavoriteNodeNum);
|
|
nodeDB->set_favorite(false, graphics::UIRenderer::currentFavoriteNodeNum);
|
|
screen->setFrames(graphics::Screen::FOCUS_DEFAULT);
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::traceRouteMenu()
|
|
{
|
|
screen->showNodePicker("Node to Trace", 30000, [](uint32_t nodenum) -> void {
|
|
LOG_INFO("Menu: Node picker selected node 0x%08x, traceRouteModule=%p", nodenum, traceRouteModule);
|
|
if (traceRouteModule) {
|
|
traceRouteModule->startTraceRoute(nodenum);
|
|
}
|
|
});
|
|
}
|
|
|
|
void menuHandler::testMenu()
|
|
{
|
|
|
|
enum optionsNumbers { Back, NumberPicker, ShowChirpy, TestAnnounce };
|
|
static const char *optionsArray[5] = {"Back"};
|
|
static int optionsEnumArray[5] = {Back};
|
|
int options = 1;
|
|
|
|
optionsArray[options] = "Number Picker";
|
|
optionsEnumArray[options++] = NumberPicker;
|
|
|
|
optionsArray[options] = screen->isFrameHidden("chirpy") ? "Show Chirpy" : "Hide Chirpy";
|
|
optionsEnumArray[options++] = ShowChirpy;
|
|
#ifdef HAS_I2S
|
|
optionsArray[options] = "Test Announce";
|
|
optionsEnumArray[options++] = TestAnnounce;
|
|
#endif
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Hidden Test Menu";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = options;
|
|
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == NumberPicker) {
|
|
menuQueue = NumberTest;
|
|
screen->runNow();
|
|
} else if (selected == ShowChirpy) {
|
|
screen->toggleFrameVisibility("chirpy");
|
|
screen->setFrames(Screen::FOCUS_SYSTEM);
|
|
|
|
} else if (selected == TestAnnounce) {
|
|
#ifdef HAS_I2S
|
|
audioThread->readAloud("This is a test of the emergency broadcast system. This is only a test.");
|
|
#endif
|
|
} else {
|
|
menuQueue = SystemBaseMenu;
|
|
screen->runNow();
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::numberTest()
|
|
{
|
|
screen->showNumberPicker("Verify Nodenum:\n ", 30000, 8, true, [](uint32_t number_picked) -> void {
|
|
LOG_DEBUG("Nodenum: 0x%08x", number_picked);
|
|
keyVerificationModule->sendInitialRequest(number_picked);
|
|
});
|
|
}
|
|
|
|
void menuHandler::wifiBaseMenu()
|
|
{
|
|
enum optionsNumbers { Back, Wifi_toggle };
|
|
|
|
static const char *optionsArray[] = {"Back", "WiFi Toggle"};
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "WiFi Menu";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 2;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == Wifi_toggle) {
|
|
menuQueue = WifiToggleMenu;
|
|
screen->runNow();
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::wifiToggleMenu()
|
|
{
|
|
enum optionsNumbers { Back, Wifi_disable, Wifi_enable };
|
|
|
|
static const char *optionsArray[] = {"Back", "WiFi Disabled", "WiFi Enabled"};
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "WiFi Actions";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 3;
|
|
if (config.network.wifi_enabled == true)
|
|
bannerOptions.InitialSelected = 2;
|
|
else
|
|
bannerOptions.InitialSelected = 1;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == Wifi_disable) {
|
|
config.network.wifi_enabled = false;
|
|
config.bluetooth.enabled = true;
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
|
} else if (selected == Wifi_enable) {
|
|
config.network.wifi_enabled = true;
|
|
config.bluetooth.enabled = false;
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::screenOptionsMenu()
|
|
{
|
|
// Check if brightness is supported
|
|
#if defined(T_DECK)
|
|
// TDeck Doesn't seem to support brightness at all, at least not reliably
|
|
bool hasSupportBrightness = false;
|
|
#elif defined(ST7789_CS) || defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107)
|
|
bool hasSupportBrightness = true;
|
|
#else
|
|
bool hasSupportBrightness = false;
|
|
#endif
|
|
|
|
enum optionsNumbers { Back, Brightness, FrameToggles, DisplayUnits, MessageBubbles, Theme };
|
|
static const char *optionsArray[7] = {"Back"};
|
|
static int optionsEnumArray[7] = {Back};
|
|
int options = 1;
|
|
|
|
// Only show brightness for B&W displays
|
|
if (hasSupportBrightness) {
|
|
optionsArray[options] = "Brightness";
|
|
optionsEnumArray[options++] = Brightness;
|
|
}
|
|
|
|
optionsArray[options] = "Frame Visibility";
|
|
optionsEnumArray[options++] = FrameToggles;
|
|
|
|
optionsArray[options] = "Display Units";
|
|
optionsEnumArray[options++] = DisplayUnits;
|
|
|
|
optionsArray[options] = "Message Bubbles";
|
|
optionsEnumArray[options++] = MessageBubbles;
|
|
|
|
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
optionsArray[options] = "Theme";
|
|
optionsEnumArray[options++] = Theme;
|
|
#endif
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Display Options";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = options;
|
|
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == Brightness) {
|
|
menuHandler::menuQueue = menuHandler::BrightnessPicker;
|
|
screen->runNow();
|
|
} else if (selected == FrameToggles) {
|
|
menuHandler::menuQueue = menuHandler::FrameToggles;
|
|
screen->runNow();
|
|
} else if (selected == DisplayUnits) {
|
|
menuHandler::menuQueue = menuHandler::DisplayUnits;
|
|
screen->runNow();
|
|
} else if (selected == MessageBubbles) {
|
|
menuHandler::menuQueue = menuHandler::MessageBubblesMenu;
|
|
screen->runNow();
|
|
} else if (selected == Theme) {
|
|
menuHandler::menuQueue = menuHandler::ThemeMenu;
|
|
screen->runNow();
|
|
} else {
|
|
menuQueue = SystemBaseMenu;
|
|
screen->runNow();
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::powerMenu()
|
|
{
|
|
|
|
enum optionsNumbers { Back, Reboot, Shutdown, MUI };
|
|
static const char *optionsArray[4] = {"Back"};
|
|
static int optionsEnumArray[4] = {Back};
|
|
int options = 1;
|
|
|
|
optionsArray[options] = "Reboot";
|
|
optionsEnumArray[options++] = Reboot;
|
|
|
|
optionsArray[options] = "Shutdown";
|
|
optionsEnumArray[options++] = Shutdown;
|
|
|
|
#if HAS_TFT
|
|
optionsArray[options] = "Switch to MUI";
|
|
optionsEnumArray[options++] = MUI;
|
|
#endif
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Reboot / Shutdown";
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
bannerOptions.message = "Power";
|
|
}
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = options;
|
|
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == Reboot) {
|
|
menuHandler::menuQueue = menuHandler::RebootMenu;
|
|
screen->runNow();
|
|
} else if (selected == Shutdown) {
|
|
menuHandler::menuQueue = menuHandler::ShutdownMenu;
|
|
screen->runNow();
|
|
} else if (selected == MUI) {
|
|
menuHandler::menuQueue = menuHandler::MuiPicker;
|
|
screen->runNow();
|
|
} else {
|
|
menuQueue = SystemBaseMenu;
|
|
screen->runNow();
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::keyVerificationInitMenu()
|
|
{
|
|
screen->showNodePicker("Node to Verify", 30000,
|
|
[](uint32_t selected) -> void { keyVerificationModule->sendInitialRequest(selected); });
|
|
}
|
|
|
|
void menuHandler::keyVerificationFinalPrompt()
|
|
{
|
|
char message[40] = {0};
|
|
memset(message, 0, sizeof(message));
|
|
sprintf(message, "Verification: \n");
|
|
keyVerificationModule->generateVerificationCode(message + 15); // send the toPhone packet
|
|
|
|
if (screen) {
|
|
static const char *optionsArray[] = {"Reject", "Accept"};
|
|
graphics::BannerOverlayOptions options;
|
|
options.message = message;
|
|
options.durationMs = 30000;
|
|
options.optionsArrayPtr = optionsArray;
|
|
options.optionsCount = 2;
|
|
options.notificationType = graphics::notificationTypeEnum::selection_picker;
|
|
options.bannerCallback = [=](int selected) {
|
|
if (selected == 1) {
|
|
keyVerificationModule->commitVerifiedRemoteNode();
|
|
}
|
|
};
|
|
screen->showOverlayBanner(options);
|
|
}
|
|
}
|
|
|
|
void menuHandler::frameTogglesMenu()
|
|
{
|
|
enum optionsNumbers {
|
|
Finish,
|
|
nodelist_nodes,
|
|
nodelist_location,
|
|
nodelist_lastheard,
|
|
nodelist_hopsignal,
|
|
nodelist_distance,
|
|
nodelist_bearings,
|
|
gps_position,
|
|
lora,
|
|
clock,
|
|
show_favorites,
|
|
show_env_telemetry,
|
|
show_aq_telemetry,
|
|
show_power,
|
|
enumEnd
|
|
};
|
|
static const char *optionsArray[enumEnd] = {"Finish"};
|
|
static int optionsEnumArray[enumEnd] = {Finish};
|
|
int options = 1;
|
|
|
|
// Track last selected index (not enum value!)
|
|
static int lastSelectedIndex = 0;
|
|
|
|
#ifndef USE_EINK
|
|
optionsArray[options] = screen->isFrameHidden("nodelist_nodes") ? "Show Node Lists" : "Hide Node Lists";
|
|
optionsEnumArray[options++] = nodelist_nodes;
|
|
#else
|
|
optionsArray[options] = screen->isFrameHidden("nodelist_lastheard") ? "Show NL - Last Heard" : "Hide NL - Last Heard";
|
|
optionsEnumArray[options++] = nodelist_lastheard;
|
|
optionsArray[options] = screen->isFrameHidden("nodelist_hopsignal") ? "Show NL - Hops/Signal" : "Hide NL - Hops/Signal";
|
|
optionsEnumArray[options++] = nodelist_hopsignal;
|
|
#endif
|
|
|
|
#if HAS_GPS
|
|
#ifndef USE_EINK
|
|
optionsArray[options] = screen->isFrameHidden("nodelist_location") ? "Show Position Lists" : "Hide Position Lists";
|
|
optionsEnumArray[options++] = nodelist_location;
|
|
#else
|
|
optionsArray[options] = screen->isFrameHidden("nodelist_distance") ? "Show NL - Distance" : "Hide NL - Distance";
|
|
optionsEnumArray[options++] = nodelist_distance;
|
|
optionsArray[options] = screen->isFrameHidden("nodelist_bearings") ? "Show NL - Bearings" : "Hide NL - Bearings";
|
|
optionsEnumArray[options++] = nodelist_bearings;
|
|
#endif
|
|
|
|
optionsArray[options] = screen->isFrameHidden("gps") ? "Show Position" : "Hide Position";
|
|
optionsEnumArray[options++] = gps_position;
|
|
#endif
|
|
|
|
optionsArray[options] = screen->isFrameHidden("lora") ? "Show LoRa" : "Hide LoRa";
|
|
optionsEnumArray[options++] = lora;
|
|
|
|
optionsArray[options] = screen->isFrameHidden("clock") ? "Show Clock" : "Hide Clock";
|
|
optionsEnumArray[options++] = clock;
|
|
|
|
optionsArray[options] = screen->isFrameHidden("show_favorites") ? "Show Favorites" : "Hide Favorites";
|
|
optionsEnumArray[options++] = show_favorites;
|
|
|
|
optionsArray[options] = moduleConfig.telemetry.environment_screen_enabled ? "Hide Env. Telemetry" : "Show Env. Telemetry";
|
|
optionsEnumArray[options++] = show_env_telemetry;
|
|
|
|
optionsArray[options] = moduleConfig.telemetry.air_quality_screen_enabled ? "Hide AQ Telemetry" : "Show AQ Telemetry";
|
|
optionsEnumArray[options++] = show_aq_telemetry;
|
|
|
|
optionsArray[options] = moduleConfig.telemetry.power_screen_enabled ? "Hide Power" : "Show Power";
|
|
optionsEnumArray[options++] = show_power;
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Show/Hide Frames";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = options;
|
|
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
|
bannerOptions.InitialSelected = lastSelectedIndex; // Use index, not enum value
|
|
|
|
bannerOptions.bannerCallback = [options](int selected) mutable -> void {
|
|
// Find the index of selected in optionsEnumArray
|
|
int idx = 0;
|
|
for (; idx < options; ++idx) {
|
|
if (optionsEnumArray[idx] == selected)
|
|
break;
|
|
}
|
|
lastSelectedIndex = idx;
|
|
|
|
if (selected == Finish) {
|
|
screen->setFrames(Screen::FOCUS_DEFAULT);
|
|
} else if (selected == nodelist_nodes) {
|
|
screen->toggleFrameVisibility("nodelist_nodes");
|
|
menuHandler::menuQueue = menuHandler::FrameToggles;
|
|
screen->runNow();
|
|
} else if (selected == nodelist_location) {
|
|
screen->toggleFrameVisibility("nodelist_location");
|
|
menuHandler::menuQueue = menuHandler::FrameToggles;
|
|
screen->runNow();
|
|
} else if (selected == nodelist_lastheard) {
|
|
screen->toggleFrameVisibility("nodelist_lastheard");
|
|
menuHandler::menuQueue = menuHandler::FrameToggles;
|
|
screen->runNow();
|
|
} else if (selected == nodelist_hopsignal) {
|
|
screen->toggleFrameVisibility("nodelist_hopsignal");
|
|
menuHandler::menuQueue = menuHandler::FrameToggles;
|
|
screen->runNow();
|
|
} else if (selected == nodelist_distance) {
|
|
screen->toggleFrameVisibility("nodelist_distance");
|
|
menuHandler::menuQueue = menuHandler::FrameToggles;
|
|
screen->runNow();
|
|
} else if (selected == nodelist_bearings) {
|
|
screen->toggleFrameVisibility("nodelist_bearings");
|
|
menuHandler::menuQueue = menuHandler::FrameToggles;
|
|
screen->runNow();
|
|
} else if (selected == gps_position) {
|
|
screen->toggleFrameVisibility("gps");
|
|
menuHandler::menuQueue = menuHandler::FrameToggles;
|
|
screen->runNow();
|
|
} else if (selected == lora) {
|
|
screen->toggleFrameVisibility("lora");
|
|
menuHandler::menuQueue = menuHandler::FrameToggles;
|
|
screen->runNow();
|
|
} else if (selected == clock) {
|
|
screen->toggleFrameVisibility("clock");
|
|
menuHandler::menuQueue = menuHandler::FrameToggles;
|
|
screen->runNow();
|
|
} else if (selected == show_favorites) {
|
|
screen->toggleFrameVisibility("show_favorites");
|
|
menuHandler::menuQueue = menuHandler::FrameToggles;
|
|
screen->runNow();
|
|
} else if (selected == show_env_telemetry) {
|
|
moduleConfig.telemetry.environment_screen_enabled = !moduleConfig.telemetry.environment_screen_enabled;
|
|
menuHandler::menuQueue = menuHandler::FrameToggles;
|
|
screen->runNow();
|
|
} else if (selected == show_aq_telemetry) {
|
|
moduleConfig.telemetry.air_quality_screen_enabled = !moduleConfig.telemetry.air_quality_screen_enabled;
|
|
menuHandler::menuQueue = menuHandler::FrameToggles;
|
|
screen->runNow();
|
|
} else if (selected == show_power) {
|
|
moduleConfig.telemetry.power_screen_enabled = !moduleConfig.telemetry.power_screen_enabled;
|
|
menuHandler::menuQueue = menuHandler::FrameToggles;
|
|
screen->runNow();
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::displayUnitsMenu()
|
|
{
|
|
enum optionsNumbers { Back, MetricUnits, ImperialUnits };
|
|
|
|
static const char *optionsArray[] = {"Back", "Metric", "Imperial"};
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = " Select display units";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 3;
|
|
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL)
|
|
bannerOptions.InitialSelected = 2;
|
|
else
|
|
bannerOptions.InitialSelected = 1;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == MetricUnits) {
|
|
config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_METRIC;
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
} else if (selected == ImperialUnits) {
|
|
config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL;
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
} else {
|
|
menuHandler::menuQueue = menuHandler::ScreenOptionsMenu;
|
|
screen->runNow();
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::messageBubblesMenu()
|
|
{
|
|
enum optionsNumbers { Back, ShowBubbles, HideBubbles };
|
|
|
|
static const char *optionsArray[] = {"Back", "Show Bubbles", "Hide Bubbles"};
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Message Bubbles";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = 3;
|
|
bannerOptions.InitialSelected = config.display.enable_message_bubbles ? 1 : 2;
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == ShowBubbles) {
|
|
config.display.enable_message_bubbles = true;
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
LOG_INFO("Message bubbles enabled");
|
|
} else if (selected == HideBubbles) {
|
|
config.display.enable_message_bubbles = false;
|
|
service->reloadConfig(SEGMENT_CONFIG);
|
|
LOG_INFO("Message bubbles disabled");
|
|
} else {
|
|
menuHandler::menuQueue = menuHandler::ScreenOptionsMenu;
|
|
screen->runNow();
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::themeMenu()
|
|
{
|
|
// Build menu dynamically from the theme table.
|
|
// Only visible themes appear!
|
|
// Slot budget: 1 for "Back" + up to kMaxThemesInMenu visible themes.
|
|
// Bump kMaxThemesInMenu if you add more themes than will fit here.
|
|
constexpr size_t kMaxThemesInMenu = 15;
|
|
const size_t visibleCount = getVisibleThemeCount();
|
|
static const char *optionsArray[kMaxThemesInMenu + 1] = {"Back"};
|
|
const size_t shownCount = (visibleCount < kMaxThemesInMenu) ? visibleCount : kMaxThemesInMenu;
|
|
const int options = static_cast<int>(shownCount) + 1; // +1 for Back
|
|
|
|
for (size_t i = 0; i < shownCount; i++) {
|
|
optionsArray[i + 1] = getVisibleThemeByIndex(i).name;
|
|
}
|
|
|
|
BannerOverlayOptions bannerOptions;
|
|
bannerOptions.message = "Theme";
|
|
bannerOptions.optionsArrayPtr = optionsArray;
|
|
bannerOptions.optionsCount = options;
|
|
|
|
// Highlight the currently active theme (visible index + 1 for the Back
|
|
// offset). If the active theme is hidden, leave selection on "Back".
|
|
const size_t activeVisible = getActiveVisibleThemeIndex();
|
|
bannerOptions.InitialSelected = (activeVisible == SIZE_MAX) ? 0 : static_cast<int>(activeVisible) + 1;
|
|
|
|
bannerOptions.bannerCallback = [](int selected) -> void {
|
|
if (selected == 0) {
|
|
// Back
|
|
menuHandler::menuQueue = menuHandler::ScreenOptionsMenu;
|
|
screen->runNow();
|
|
} else {
|
|
// Selection is an index into the VISIBLE themes (1-based, slot 0 is Back).
|
|
const size_t visibleIdx = static_cast<size_t>(selected - 1);
|
|
if (visibleIdx < getVisibleThemeCount()) {
|
|
// Persist the theme's uniqueIdentifier so boot-time
|
|
// resolveThemeIndex() can restore this theme on next startup.
|
|
uiconfig.screen_rgb_color = COLOR565(255, 255, (getVisibleThemeByIndex(visibleIdx).uniqueIdentifier & 0x1F) << 3);
|
|
loadThemeDefaults();
|
|
saveUIConfig();
|
|
screen->runNow();
|
|
}
|
|
}
|
|
};
|
|
screen->showOverlayBanner(bannerOptions);
|
|
}
|
|
|
|
void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
|
{
|
|
if (menuQueue != MenuNone)
|
|
test_count = 0;
|
|
switch (menuQueue) {
|
|
case MenuNone:
|
|
break;
|
|
case LoraMenu:
|
|
loraMenu();
|
|
break;
|
|
case LoraPicker:
|
|
LoraRegionPicker();
|
|
break;
|
|
case DeviceRolePicker:
|
|
deviceRolePicker();
|
|
break;
|
|
case RadioPresetPicker:
|
|
radioPresetPicker();
|
|
break;
|
|
case FrequencySlot:
|
|
FrequencySlotPicker();
|
|
break;
|
|
case NoTimeoutLoraPicker:
|
|
LoraRegionPicker(0);
|
|
break;
|
|
case TzPicker:
|
|
TZPicker();
|
|
break;
|
|
case TwelveHourPicker:
|
|
twelveHourPicker();
|
|
break;
|
|
case ClockFacePicker:
|
|
clockFacePicker();
|
|
break;
|
|
case ClockMenu:
|
|
clockMenu();
|
|
break;
|
|
case SystemBaseMenu:
|
|
systemBaseMenu();
|
|
break;
|
|
case PositionBaseMenu:
|
|
positionBaseMenu();
|
|
break;
|
|
case NodeBaseMenu:
|
|
nodeListMenu();
|
|
break;
|
|
#if !MESHTASTIC_EXCLUDE_GPS
|
|
case GpsToggleMenu:
|
|
GPSToggleMenu();
|
|
break;
|
|
case GpsFormatMenu:
|
|
GPSFormatMenu();
|
|
break;
|
|
case GpsSmartPositionMenu:
|
|
GPSSmartPositionMenu();
|
|
break;
|
|
case GpsUpdateIntervalMenu:
|
|
GPSUpdateIntervalMenu();
|
|
break;
|
|
case GpsPositionBroadcastMenu:
|
|
GPSPositionBroadcastMenu();
|
|
break;
|
|
#endif
|
|
case CompassPointNorthMenu:
|
|
compassNorthMenu();
|
|
break;
|
|
case ResetNodeDbMenu:
|
|
resetNodeDBMenu();
|
|
break;
|
|
case BuzzerModeMenuPicker:
|
|
BuzzerModeMenu();
|
|
break;
|
|
case MuiPicker:
|
|
switchToMUIMenu();
|
|
break;
|
|
case BrightnessPicker:
|
|
BrightnessPickerMenu();
|
|
break;
|
|
case NodeNameLengthMenu:
|
|
nodeNameLengthMenu();
|
|
break;
|
|
case RebootMenu:
|
|
rebootMenu();
|
|
break;
|
|
case ShutdownMenu:
|
|
shutdownMenu();
|
|
break;
|
|
case NodePickerMenu:
|
|
NodePicker();
|
|
break;
|
|
case ManageNodeMenu:
|
|
manageNodeMenu();
|
|
break;
|
|
case RemoveFavorite:
|
|
removeFavoriteMenu();
|
|
break;
|
|
case TraceRouteMenu:
|
|
traceRouteMenu();
|
|
break;
|
|
case TestMenu:
|
|
testMenu();
|
|
break;
|
|
case NumberTest:
|
|
numberTest();
|
|
break;
|
|
case WifiToggleMenu:
|
|
wifiToggleMenu();
|
|
break;
|
|
case KeyVerificationInit:
|
|
keyVerificationInitMenu();
|
|
break;
|
|
case KeyVerificationFinalPrompt:
|
|
keyVerificationFinalPrompt();
|
|
break;
|
|
case BluetoothToggleMenu:
|
|
bluetoothToggleMenu();
|
|
break;
|
|
case ScreenOptionsMenu:
|
|
screenOptionsMenu();
|
|
break;
|
|
case PowerMenu:
|
|
powerMenu();
|
|
break;
|
|
case FrameToggles:
|
|
frameTogglesMenu();
|
|
break;
|
|
case DisplayUnits:
|
|
displayUnitsMenu();
|
|
break;
|
|
case ThrottleMessage:
|
|
screen->showSimpleBanner("Too Many Attempts\nTry again in 60 seconds.", 5000);
|
|
break;
|
|
case MessageResponseMenu:
|
|
messageResponseMenu();
|
|
break;
|
|
case ReplyMenu:
|
|
replyMenu();
|
|
break;
|
|
case DeleteMessagesMenu:
|
|
deleteMessagesMenu();
|
|
break;
|
|
case MessageViewModeMenu:
|
|
messageViewModeMenu();
|
|
break;
|
|
case MessageBubblesMenu:
|
|
messageBubblesMenu();
|
|
break;
|
|
case ThemeMenu:
|
|
themeMenu();
|
|
break;
|
|
case HamModeConfirm:
|
|
hamModeConfirmMenu();
|
|
break;
|
|
case LicensedToNormalConfirm:
|
|
licensedToNormalConfirmMenu();
|
|
break;
|
|
}
|
|
menuQueue = MenuNone;
|
|
}
|
|
|
|
void menuHandler::saveUIConfig()
|
|
{
|
|
nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uiconfig);
|
|
}
|
|
|
|
} // namespace graphics
|
|
|
|
#endif
|