Files
ratdeck/src/ui/screens/LvSettingsScreen.cpp
T

2168 lines
89 KiB
C++

#include "LvSettingsScreen.h"
#include "ui/Theme.h"
#include "ui/LvTheme.h"
#include "ui/LvInput.h"
#include "config/Config.h"
#include "config/UserConfig.h"
#include "ui/screens/LvTimezoneScreen.h" // For TIMEZONE_TABLE
#include "storage/FlashStore.h"
#include "storage/SDStore.h"
#include "radio/SX1262.h"
#include "audio/AudioNotify.h"
#include "hal/Power.h"
#include "transport/WiFiInterface.h"
#include "reticulum/ReticulumManager.h"
#include "reticulum/IdentityManager.h"
#include <Arduino.h>
#include <esp_system.h>
#include <nvs_flash.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include "fonts/fonts.h"
struct RadioPresetLv {
const char* name;
uint8_t sf; uint32_t bw; uint8_t cr; int8_t txPower; long preamble;
};
static const RadioPresetLv LV_PRESETS[] = {
{"Short Turbo", 7, 500000, 5, 14, 18},
{"Short Fast", 7, 250000, 5, 14, 18},
{"Short Slow", 8, 250000, 5, 14, 18},
{"Medium Fast", 9, 250000, 5, 17, 18},
{"Medium Slow", 10, 250000, 5, 17, 18},
{"Long Turbo", 11, 500000, 8, 22, 18},
{"Long Fast", 11, 250000, 5, 22, 18},
{"Long Moderate", 11, 125000, 8, 22, 18},
};
static constexpr int LV_NUM_PRESETS = 8;
namespace {
bool labelEq(const char* a, const char* b) {
return a && b && strcmp(a, b) == 0;
}
const char* onOff(bool enabled) {
return enabled ? "ON" : "OFF";
}
const char* wifiModeLabel(RatWiFiMode mode) {
switch (mode) {
case RAT_WIFI_AP: return "Hotspot";
case RAT_WIFI_STA: return "Client";
case RAT_WIFI_OFF:
default: return "Off";
}
}
String maskedValue(const String& value) {
if (value.isEmpty()) return String("");
int len = constrain((int)value.length(), 4, 12);
String masked;
masked.reserve(len);
for (int i = 0; i < len; i++) masked += '*';
return masked;
}
bool isWiFiSSIDLabel(const char* label) {
return labelEq(label, "WiFi SSID");
}
bool isWiFiPasswordLabel(const char* label) {
return labelEq(label, "WiFi Password");
}
size_t selectedWiFiSlot(const UserSettings& s) {
return s.wifiSTASelected < WIFI_STA_MAX_NETWORKS ? s.wifiSTASelected : 0;
}
void ensureWiFiSlot(UserSettings& s, size_t slot) {
while (s.wifiSTANetworks.size() <= slot && s.wifiSTANetworks.size() < WIFI_STA_MAX_NETWORKS) {
s.wifiSTANetworks.push_back({});
}
}
String wifiProfileValue(const UserSettings& s, size_t slot) {
String label = String(slot + 1);
label += " ";
if (slot < s.wifiSTANetworks.size() && !s.wifiSTANetworks[slot].ssid.isEmpty()) {
label += s.wifiSTANetworks[slot].ssid;
} else {
label += "empty";
}
return label;
}
String selectedWiFiSSID(const UserSettings& s) {
size_t slot = selectedWiFiSlot(s);
if (slot >= s.wifiSTANetworks.size()) return String("");
return s.wifiSTANetworks[slot].ssid;
}
void clipLabel(lv_obj_t* lbl, int width) {
lv_obj_set_width(lbl, width);
lv_label_set_long_mode(lbl, LV_LABEL_LONG_CLIP);
}
bool extractReleaseTag(const String& payload, char* out, size_t outLen) {
if (!out || outLen == 0) return false;
out[0] = '\0';
int key = payload.indexOf("\"tag_name\"");
if (key < 0) return false;
int colon = payload.indexOf(':', key);
if (colon < 0) return false;
int start = payload.indexOf('"', colon + 1);
if (start < 0) return false;
int end = payload.indexOf('"', start + 1);
if (end <= start + 1) return false;
String tag = payload.substring(start + 1, end);
if (tag.startsWith("v") || tag.startsWith("V")) tag.remove(0, 1);
tag.trim();
if (tag.isEmpty()) return false;
strlcpy(out, tag.c_str(), outLen);
return out[0] != '\0';
}
} // namespace
int LvSettingsScreen::detectPreset() const {
if (!_cfg) return -1;
auto& s = _cfg->settings();
// Check if frequency matches the current region's default
uint32_t regionFreq = REGION_FREQ[constrain(s.radioRegion, 0, REGION_COUNT - 1)];
if (s.loraFrequency != regionFreq) return -1; // Custom frequency: no preset match
for (int i = 0; i < LV_NUM_PRESETS; i++) {
if (s.loraSF == LV_PRESETS[i].sf && s.loraBW == LV_PRESETS[i].bw
&& s.loraCR == LV_PRESETS[i].cr && s.loraTxPower == LV_PRESETS[i].txPower)
return i;
}
return -1;
}
void LvSettingsScreen::applyPreset(int presetIdx) {
if (!_cfg || presetIdx < 0 || presetIdx >= LV_NUM_PRESETS) return;
auto& s = _cfg->settings();
const auto& p = LV_PRESETS[presetIdx];
s.loraSF = p.sf; s.loraBW = p.bw; s.loraCR = p.cr;
s.loraTxPower = p.txPower; s.loraPreamble = p.preamble;
s.loraFrequency = REGION_FREQ[constrain(s.radioRegion, 0, REGION_COUNT - 1)];
}
bool LvSettingsScreen::isEditable(int idx) const {
if (idx < 0 || idx >= (int)_items.size()) return false;
auto t = _items[idx].type;
return t == SettingType::INTEGER || t == SettingType::TOGGLE
|| t == SettingType::ENUM_CHOICE || t == SettingType::ACTION
|| t == SettingType::TEXT_INPUT;
}
void LvSettingsScreen::skipToNextEditable(int dir) {
int n = _catRangeEnd;
int start = _selectedIdx;
for (int i = 0; i < (n - _catRangeStart); i++) {
_selectedIdx += dir;
if (_selectedIdx < _catRangeStart) _selectedIdx = _catRangeStart;
if (_selectedIdx >= n) _selectedIdx = n - 1;
if (isEditable(_selectedIdx)) return;
if (_selectedIdx == _catRangeStart && dir < 0) return;
if (_selectedIdx == n - 1 && dir > 0) return;
}
_selectedIdx = start;
}
bool LvSettingsScreen::settingNeedsReboot(const SettingItem& item) const {
if (!_cfg) return false;
const auto& s = _cfg->settings();
if (labelEq(item.label, "WiFi Mode")) return s.wifiMode != _rebootSnap.wifiMode;
if (labelEq(item.label, "LoRa Radio")) return loraSettingsChanged();
if (labelEq(item.label, "Active WiFi")) return s.wifiSTASelected != _rebootSnap.wifiSTASelected;
if (isWiFiSSIDLabel(item.label) || isWiFiPasswordLabel(item.label)) return interfaceSettingsChanged();
if (labelEq(item.label, "WiFi Scan") || labelEq(item.label, "Forget WiFi")) return interfaceSettingsChanged();
if (labelEq(item.label, "TCP Relay") || labelEq(item.label, "Relay Host") ||
labelEq(item.label, "Relay Port")) return tcpSettingsChanged();
if (labelEq(item.label, "LAN Discovery")) return s.autoIfaceEnabled != _rebootSnap.autoIfaceEnabled;
if (labelEq(item.label, "SD Message Store")) return storageSettingsChanged();
return false;
}
bool LvSettingsScreen::categoryNeedsReboot(int catIdx) const {
if (catIdx < 0 || catIdx >= (int)_categories.size()) return false;
if (labelEq(_categories[catIdx].name, "LoRa")) {
return loraSettingsChanged();
}
if (labelEq(_categories[catIdx].name, "Interfaces")) {
return interfaceSettingsChanged() || tcpSettingsChanged();
}
if (labelEq(_categories[catIdx].name, "Storage & Maintenance")) {
return storageSettingsChanged();
}
return false;
}
bool LvSettingsScreen::confirmableAction(const SettingItem& item) const {
return labelEq(item.label, "Developer Radio Controls")
|| labelEq(item.label, "Format SD Card")
|| labelEq(item.label, "Erase rsDeck SD Data")
|| labelEq(item.label, "Erase Device");
}
bool LvSettingsScreen::armedAction(const SettingItem& item) const {
return (_confirmingInitSD && labelEq(item.label, "Format SD Card")) ||
(_confirmingWipeSD && labelEq(item.label, "Erase rsDeck SD Data")) ||
(_confirmingReset && labelEq(item.label, "Erase Device")) ||
(_confirmingDevMode && labelEq(item.label, "Developer Radio Controls"));
}
bool LvSettingsScreen::destructiveAction(const SettingItem& item) const {
return labelEq(item.label, "Format SD Card")
|| labelEq(item.label, "Erase rsDeck SD Data")
|| labelEq(item.label, "Erase Device");
}
const char* LvSettingsScreen::confirmationTitle() const {
if (_confirmingInitSD) return "ARMED: FORMAT SD CARD";
if (_confirmingWipeSD) return "ARMED: ERASE SD DATA";
if (_confirmingReset) return "ARMED: ERASE DEVICE";
if (_confirmingDevMode) return "ARMED: UNLOCK RF CONTROLS";
return nullptr;
}
const char* LvSettingsScreen::confirmationDetail() const {
if (_confirmingInitSD) return "Hold trackball to format. Esc cancels.";
if (_confirmingWipeSD) return "Hold trackball to erase SD data. Esc cancels.";
if (_confirmingReset) return "Hold trackball to erase device. Esc cancels.";
if (_confirmingDevMode) return "Hold trackball to unlock. Esc cancels.";
return nullptr;
}
bool LvSettingsScreen::hasPendingConfirmation() const {
return _confirmingInitSD || _confirmingWipeSD || _confirmingReset || _confirmingDevMode;
}
void LvSettingsScreen::clearConfirmations() {
_confirmingInitSD = false;
_confirmingWipeSD = false;
_confirmingReset = false;
_confirmingDevMode = false;
}
void LvSettingsScreen::runFormatSD() {
_confirmingInitSD = false;
if (!_sd || !_sd->isReady()) {
if (_ui) _ui->lvStatusBar().showToast("No SD card", 1200);
rebuildItemList();
return;
}
if (_ui) _ui->lvStatusBar().showToast("Formatting SD card...", 2000);
bool ok = _sd->formatForRsDeck();
if (_ui) _ui->lvStatusBar().showToast(ok ? "SD card formatted" : "SD format failed", 1500);
rebuildItemList();
}
void LvSettingsScreen::runWipeSD() {
_confirmingWipeSD = false;
if (!_sd || !_sd->isReady()) {
if (_ui) _ui->lvStatusBar().showToast("No SD card", 1200);
rebuildItemList();
return;
}
if (_ui) _ui->lvStatusBar().showToast("Erasing rsDeck SD data...", 2000);
bool ok = _sd->wipeRsDeck();
if (_ui) _ui->lvStatusBar().showToast(ok ? "SD data erased" : "SD erase failed", 1500);
rebuildItemList();
}
void LvSettingsScreen::runFactoryReset() {
_confirmingReset = false;
if (_ui) _ui->lvStatusBar().showToast("Erasing device...", 3000);
if (_sd && _sd->isReady()) _sd->wipeRsDeck();
if (_flash) _flash->format();
nvs_flash_erase();
delay(1500); // Long enough for key state to clear before reboot
ESP.restart();
}
void LvSettingsScreen::runEnableDevMode() {
_confirmingDevMode = false;
if (!_cfg) {
rebuildItemList();
return;
}
_cfg->settings().devMode = true;
applyAndSave();
buildItems();
enterCategory(_categoryIdx);
if (_ui) _ui->lvStatusBar().showToast("Developer radio controls unlocked", 1500);
}
void LvSettingsScreen::startFirmwareCheck() {
if (WiFi.status() != WL_CONNECTED) {
if (_ui) _ui->lvStatusBar().showToast("Connect WiFi to check firmware", 2500);
return;
}
if (_fwCheckState == FirmwareCheckState::RUNNING) {
if (_ui) _ui->lvStatusBar().showToast("Firmware check already running", 1200);
return;
}
_fwCheckVersion[0] = '\0';
_fwCheckState = FirmwareCheckState::RUNNING;
if (xTaskCreatePinnedToCore(
firmwareCheckTask,
"fw-check",
6144,
this,
1,
&_fwCheckTask,
0) != pdPASS) {
_fwCheckTask = nullptr;
_fwCheckState = FirmwareCheckState::FAILED;
} else if (_ui) {
_ui->lvStatusBar().showToast("Checking firmware release...", 1200);
}
}
void LvSettingsScreen::firmwareCheckTask(void* arg) {
auto* self = static_cast<LvSettingsScreen*>(arg);
if (!self) {
vTaskDelete(nullptr);
return;
}
FirmwareCheckState result = FirmwareCheckState::FAILED;
char version[sizeof(self->_fwCheckVersion)] = {};
HTTPClient http;
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.setTimeout(5000);
if (http.begin("https://api.github.com/repos/ratspeak/rsDeck/releases/latest")) {
http.addHeader("Accept", "application/vnd.github.v3+json");
int httpCode = http.GET();
if (httpCode == 200) {
WiFiClient* stream = http.getStreamPtr();
String payload;
payload.reserve(1536);
unsigned long start = millis();
while (stream && http.connected() && millis() - start < 5000 && payload.length() < 2048) {
while (stream->available() && payload.length() < 2048) {
payload += (char)stream->read();
if (payload.indexOf("\"tag_name\"") >= 0 && payload.indexOf('\n', payload.indexOf("\"tag_name\"")) >= 0) {
break;
}
}
if (extractReleaseTag(payload, version, sizeof(version))) break;
delay(10);
}
if (extractReleaseTag(payload, version, sizeof(version))) {
result = strcmp(version, RSDECK_VERSION_STRING) > 0
? FirmwareCheckState::AVAILABLE
: FirmwareCheckState::CURRENT;
}
}
}
http.end();
if (version[0]) strlcpy(self->_fwCheckVersion, version, sizeof(self->_fwCheckVersion));
self->_fwCheckState = result;
self->_fwCheckTask = nullptr;
vTaskDelete(nullptr);
}
void LvSettingsScreen::buildItems() {
_items.clear();
_categories.clear();
if (!_cfg) return;
auto& s = _cfg->settings();
int idx = 0;
// Identity & Device
int devStart = idx;
_items.push_back({"Firmware", SettingType::READONLY, nullptr, nullptr,
[](int) { return String(RSDECK_VERSION_STRING); }});
idx++;
_items.push_back({"LXMF Address", SettingType::READONLY, nullptr, nullptr,
[this](int) { return _destinationHash.length() > 0 ? _destinationHash : String("unknown"); }});
idx++;
_items.push_back({"Identity Hash", SettingType::READONLY, nullptr, nullptr,
[this](int) { return _identityHash; }});
idx++;
{
SettingItem nameItem;
nameItem.label = "Display Name";
nameItem.type = SettingType::TEXT_INPUT;
nameItem.textGetter = [&s]() { return s.displayName; };
nameItem.textSetter = [this, &s](const String& v) {
s.displayName = v;
// Keep active identity slot in sync
if (_idMgr && _idMgr->activeIndex() >= 0) {
_idMgr->setDisplayName(_idMgr->activeIndex(), v);
}
};
nameItem.maxTextLen = 16;
_items.push_back(nameItem);
idx++;
}
if (_idMgr && _idMgr->count() > 0) {
SettingItem idSwitch;
idSwitch.label = "Identity Slot";
idSwitch.type = SettingType::ENUM_CHOICE;
idSwitch.getter = [this]() { return _idMgr->activeIndex(); };
idSwitch.setter = [this](int v) {
if (v == _idMgr->activeIndex()) return;
// Save current display name to outgoing identity slot
if (_cfg) {
_idMgr->setDisplayName(_idMgr->activeIndex(), _cfg->settings().displayName);
}
RNS::Identity newId;
if (_idMgr->switchTo(v, newId)) {
// Load incoming identity's display name into config
if (_cfg) {
_cfg->settings().displayName = _idMgr->getDisplayName(v);
}
if (_ui) _ui->lvStatusBar().showToast("Identity switched; rebooting", 2000);
applyAndSave();
delay(1000);
ESP.restart();
} else {
if (_ui) _ui->lvStatusBar().showToast("Identity switch failed", 1500);
}
};
idSwitch.minVal = 0;
idSwitch.maxVal = _idMgr->count() - 1;
idSwitch.step = 1;
for (int i = 0; i < _idMgr->count(); i++) {
auto& slot = _idMgr->identities()[i];
static char labelBufs[8][32];
if (!slot.displayName.isEmpty()) {
snprintf(labelBufs[i], sizeof(labelBufs[i]), "%s [%.8s]",
slot.displayName.c_str(), slot.hash.c_str());
} else {
snprintf(labelBufs[i], sizeof(labelBufs[i]), "%.12s", slot.hash.c_str());
}
idSwitch.enumLabels.push_back(labelBufs[i]);
}
_items.push_back(idSwitch);
idx++;
}
{
SettingItem newId;
newId.label = "Create Identity";
newId.type = SettingType::ACTION;
newId.formatter = [](int) { return String("[Enter]"); };
newId.action = [this, &s]() {
if (!_idMgr) { if (_ui) _ui->lvStatusBar().showToast("Not available", 1200); return; }
if (_idMgr->count() >= 8) { if (_ui) _ui->lvStatusBar().showToast("Identity slots full", 1200); return; }
int idx = _idMgr->createIdentity(s.displayName);
if (idx >= 0) {
if (_ui) _ui->lvStatusBar().showToast("Identity created", 1200);
buildItems();
rebuildCategoryList();
}
};
_items.push_back(newId);
idx++;
}
if (_sd && _sd->isReady()) {
SettingItem importId;
importId.label = "Import Identity";
importId.type = SettingType::ACTION;
importId.formatter = [](int) { return String("[Enter]"); };
importId.action = [this, &s]() {
if (!_sd || !_sd->isReady()) {
if (_ui) _ui->lvStatusBar().showToast("No SD card", 1200);
return;
}
if (!_sd->exists(SD_PATH_IMPORT_IDENTITY) && !_sd->exists(SD_PATH_IMPORT_ID)) {
File dir = _sd->openDir(SD_PATH_IDENTITY_DIR);
int identityFiles = 0;
while (dir) {
File entry = dir.openNextFile();
if (!entry) break;
if (!entry.isDirectory()) {
String name = entry.name();
name.toLowerCase();
bool legacyIdentityKey = name == "identity.key" || name.endsWith("/identity.key");
if (name.endsWith(".identity") || (name.endsWith(".key") && !legacyIdentityKey)) {
identityFiles++;
}
}
entry.close();
}
if (identityFiles == 0) {
if (_ui) _ui->lvStatusBar().showToast("Missing identity file", 1200);
return;
}
if (identityFiles > 1) {
if (_ui) _ui->lvStatusBar().showToast("Rename one import.identity", 1500);
return;
}
}
if (!_idMgr) {
if (_ui) _ui->lvStatusBar().showToast("Not available", 1200);
return;
}
if (_idMgr->count() >= 8) {
if (_ui) _ui->lvStatusBar().showToast("Identity slots full", 1200);
return;
}
int idx = _idMgr->importIdentity(s.displayName);
if (idx >= 0) {
if (_ui) _ui->lvStatusBar().showToast("Identity imported", 1200);
buildItems();
rebuildCategoryList();
} else if (_ui) {
_ui->lvStatusBar().showToast("Identity import failed", 1200);
}
};
_items.push_back(importId);
idx++;
}
_items.push_back({"Auto Announce", SettingType::INTEGER,
[&s]() { return s.announceInterval; }, [&s](int v) { s.announceInterval = v; },
[](int v) { return String(v) + "m"; }, 30, 60 * 6, 5}); // 30m - 6h
idx++;
_categories.push_back({"Identity & Device", devStart, idx - devStart,
[&s]() -> String {
String name = s.displayName.isEmpty() ? String("Unnamed device") : s.displayName;
if (s.devMode) name += " / Dev Enabled";
return name;
}});
// Screen & Input
int dispStart = idx;
_items.push_back({"Screen Brightness", SettingType::INTEGER,
[&s]() { return s.brightness; }, [&s](int v) { s.brightness = v; },
[](int v) { return String(v) + "%"; }, 5, 100, 5});
idx++;
{
SettingItem themeItem;
themeItem.label = "Theme";
themeItem.type = SettingType::ENUM_CHOICE;
themeItem.getter = [&s]() { return s.themeLight ? 1 : 0; };
themeItem.setter = [&s](int v) { s.themeLight = (v != 0); };
themeItem.minVal = 0; themeItem.maxVal = 1; themeItem.step = 1;
themeItem.enumLabels = {"Dark", "Light"};
_items.push_back(themeItem);
idx++;
}
_items.push_back({"Dim After", SettingType::INTEGER,
[&s]() { return s.screenDimTimeout; }, [&s](int v) { s.screenDimTimeout = v; },
[](int v) { return String(v) + "s"; }, 5, 300, 5});
idx++;
_items.push_back({"Display Off After", SettingType::INTEGER,
[&s]() { return s.screenOffTimeout; }, [&s](int v) { s.screenOffTimeout = v; },
[](int v) { return String(v) + "s"; }, 10, 600, 10});
idx++;
_items.push_back({"Key Backlight", SettingType::INTEGER,
[&s]() { return s.keyboardBrightness; }, [&s](int v) { s.keyboardBrightness = v; },
[](int v) { return v <= 0 ? String("OFF") : String(v) + "%"; }, 0, 100, 5});
idx++;
_items.push_back({"Keys Wake Light", SettingType::TOGGLE,
[&s]() { return s.keyboardAutoOn ? 1 : 0; },
[&s](int v) { s.keyboardAutoOn = (v != 0); },
[](int v) { return String(onOff(v != 0)); }});
idx++;
_items.push_back({"Keys Auto-Off", SettingType::TOGGLE,
[&s]() { return s.keyboardAutoOff ? 1 : 0; },
[&s](int v) { s.keyboardAutoOff = (v != 0); },
[](int v) { return String(onOff(v != 0)); }});
idx++;
_items.push_back({"Trackball Speed", SettingType::INTEGER,
[&s]() { return s.trackballSpeed; }, [&s](int v) { s.trackballSpeed = v; },
[](int v) { return String(v); }, 1, 5, 1});
idx++;
_categories.push_back({"Screen & Input", dispStart, idx - dispStart,
[&s]() -> String {
String summary = String("Screen ") + String(s.brightness);
summary += "% / Keys ";
summary += s.keyboardBrightness == 0 ? String("OFF") : String(s.keyboardBrightness) + "%";
return summary;
}});
// LoRa link
int radioStart = idx;
_items.push_back({"LoRa Radio", SettingType::TOGGLE,
[&s]() { return s.loraEnabled ? 1 : 0; },
[&s](int v) { s.loraEnabled = (v != 0); },
[](int v) { return String(onOff(v != 0)); }});
idx++;
{
// Region picker - always visible
SettingItem regionItem;
regionItem.label = "Region";
regionItem.type = SettingType::ENUM_CHOICE;
regionItem.getter = [&s]() { return (int)s.radioRegion; };
regionItem.setter = [this, &s](int v) {
int region = constrain(v, 0, REGION_COUNT - 1);
s.radioRegion = region;
s.loraFrequency = REGION_FREQ[region];
};
regionItem.minVal = 0; regionItem.maxVal = REGION_COUNT - 1; regionItem.step = 1;
regionItem.enumLabels = {REGION_LABELS[0], REGION_LABELS[1], REGION_LABELS[2], REGION_LABELS[3]};
_items.push_back(regionItem);
idx++;
SettingItem presetItem;
presetItem.label = "Link Preset";
presetItem.type = SettingType::ENUM_CHOICE;
presetItem.getter = [this]() { int p = detectPreset(); return (p >= 0) ? p : LV_NUM_PRESETS; };
presetItem.setter = [this](int v) { if (v >= 0 && v < LV_NUM_PRESETS) applyPreset(v); };
presetItem.minVal = 0; presetItem.maxVal = LV_NUM_PRESETS - 1; presetItem.step = 1;
presetItem.enumLabels = {};
for (int i = 0; i < LV_NUM_PRESETS; i++)
presetItem.enumLabels.push_back(LV_PRESETS[i].name);
presetItem.enumLabels.push_back("Custom");
_items.push_back(presetItem);
idx++;
}
{
SettingItem devModeItem;
devModeItem.label = "Developer Radio Controls";
devModeItem.type = SettingType::ACTION;
devModeItem.formatter = [this, &s](int) {
if (_confirmingDevMode) return String("Hold");
return s.devMode ? String("Unlocked") : String("Locked");
};
devModeItem.action = [this, &s]() {
if (s.devMode) {
s.devMode = false;
_confirmingDevMode = false;
clearConfirmations();
applyAndSave();
buildItems();
enterCategory(_categoryIdx);
if (_ui) _ui->lvStatusBar().showToast("Developer radio controls locked", 1500);
return;
}
if (!_confirmingDevMode) {
clearConfirmations();
_confirmingDevMode = true;
if (_ui) _ui->lvStatusBar().showToast("RF controls armed. Hold to unlock", 5000);
rebuildItemList();
return;
}
if (_ui) _ui->lvStatusBar().showToast("Hold trackball to unlock RF controls", 2500);
};
_items.push_back(devModeItem);
idx++;
}
// Custom radio parameters - only visible in Developer Mode
if (s.devMode) {
_items.push_back({"Frequency", SettingType::INTEGER,
[&s]() { return (int)(s.loraFrequency); },
[&s](int v) { s.loraFrequency = (uint32_t)v; },
[](int v) -> String {
char buf[20];
int mhz = v / 1000000;
int rem = v % 1000000;
if (rem == 0) {
snprintf(buf, sizeof(buf), "%d MHz", mhz);
} else {
char frac[8];
snprintf(frac, sizeof(frac), "%06d", rem);
int len = 6;
while (len > 0 && frac[len - 1] == '0') len--;
frac[len] = '\0';
snprintf(buf, sizeof(buf), "%d.%s MHz", mhz, frac);
}
return String(buf);
},
137000000, 1020000000, 125000});
idx++;
_items.push_back({"TX Power", SettingType::INTEGER,
[&s]() { return s.loraTxPower; }, [&s](int v) { s.loraTxPower = v; },
[](int v) { return String(v) + " dBm"; }, -9, 22, 1});
idx++;
_items.push_back({"Spread Factor", SettingType::INTEGER,
[&s]() { return s.loraSF; }, [&s](int v) { s.loraSF = v; },
[](int v) { return String("SF") + String(v); }, 5, 12, 1});
idx++;
_items.push_back({"Bandwidth", SettingType::ENUM_CHOICE,
[&s]() {
if (s.loraBW <= 41700) return 0;
if (s.loraBW <= 62500) return 1;
if (s.loraBW <= 125000) return 2;
if (s.loraBW <= 250000) return 3;
return 4;
},
[&s](int v) {
static const uint32_t bws[] = {41700, 62500, 125000, 250000, 500000};
s.loraBW = bws[constrain(v, 0, 4)];
},
nullptr, 0, 4, 1, {"41.7k", "62.5k", "125k", "250k", "500k"}});
idx++;
_items.push_back({"Coding Rate", SettingType::INTEGER,
[&s]() { return s.loraCR; }, [&s](int v) { s.loraCR = v; },
[](int v) { return String("4/") + String(v); }, 5, 8, 1});
idx++;
_items.push_back({"Preamble", SettingType::INTEGER,
[&s]() { return (int)s.loraPreamble; }, [&s](int v) { s.loraPreamble = v; },
[](int v) { return String(v); }, 6, 65, 1});
idx++;
}
_categories.push_back({"LoRa", radioStart, idx - radioStart,
[this]() {
if (loraSettingsChanged()) {
return String("Saved - reboot to apply");
}
int p = detectPreset();
auto& s = _cfg->settings();
if (!s.loraEnabled) return String("Off");
String label = (p >= 0) ? String(LV_PRESETS[p].name) : String("Custom");
label += " ";
label += REGION_LABELS[constrain(s.radioRegion, 0, REGION_COUNT - 1)];
if (s.devMode) label += " / Dev";
return label;
}});
// Interfaces
int netStart = idx;
_items.push_back({"WiFi Mode", SettingType::ENUM_CHOICE,
[&s]() { return (int)s.wifiMode; },
[&s](int v) {
s.wifiMode = (RatWiFiMode)v;
if (s.wifiMode != RAT_WIFI_OFF) s.wifiRestoreMode = s.wifiMode;
},
nullptr, 0, 2, 1, {"Off", "Hotspot", "Client"}});
idx++;
_items.push_back({"Active WiFi", SettingType::INTEGER,
[&s]() { return (int)selectedWiFiSlot(s) + 1; },
[&s](int v) { s.wifiSTASelected = (uint8_t)constrain(v - 1, 0, (int)WIFI_STA_MAX_NETWORKS - 1); },
[&s](int v) { return wifiProfileValue(s, constrain(v - 1, 0, (int)WIFI_STA_MAX_NETWORKS - 1)); },
1, (int)WIFI_STA_MAX_NETWORKS, 1});
idx++;
{
SettingItem ssidItem;
ssidItem.label = "WiFi SSID";
ssidItem.type = SettingType::TEXT_INPUT;
ssidItem.textGetter = [&s]() {
size_t slot = selectedWiFiSlot(s);
return slot < s.wifiSTANetworks.size() ? s.wifiSTANetworks[slot].ssid : String("");
};
ssidItem.textSetter = [&s](const String& v) {
size_t slot = selectedWiFiSlot(s);
ensureWiFiSlot(s, slot);
if (slot >= s.wifiSTANetworks.size()) return;
bool changed = s.wifiSTANetworks[slot].ssid != v;
s.wifiSTANetworks[slot].ssid = v;
if (changed || v.isEmpty()) s.wifiSTANetworks[slot].password = "";
};
ssidItem.maxTextLen = 32;
_items.push_back(ssidItem);
idx++;
}
{
SettingItem passItem;
passItem.label = "WiFi Password";
passItem.type = SettingType::TEXT_INPUT;
passItem.textGetter = [&s]() {
size_t slot = selectedWiFiSlot(s);
return slot < s.wifiSTANetworks.size() ? s.wifiSTANetworks[slot].password : String("");
};
passItem.textSetter = [&s](const String& v) {
size_t slot = selectedWiFiSlot(s);
ensureWiFiSlot(s, slot);
if (slot < s.wifiSTANetworks.size()) s.wifiSTANetworks[slot].password = v;
};
passItem.maxTextLen = 64;
_items.push_back(passItem);
idx++;
}
{
SettingItem scanItem;
scanItem.label = "WiFi Scan";
scanItem.type = SettingType::ACTION;
scanItem.formatter = [](int) { return String("[Enter]"); };
scanItem.action = [this, &s]() {
_wifiTargetSlot = selectedWiFiSlot(s);
showWifiPicker();
};
_items.push_back(scanItem);
idx++;
}
{
SettingItem forgetItem;
forgetItem.label = "Forget WiFi";
forgetItem.type = SettingType::ACTION;
forgetItem.formatter = [&s](int) {
String ssid = selectedWiFiSSID(s);
return ssid.isEmpty() ? String("Empty") : String("[Enter]");
};
forgetItem.action = [this, &s]() {
size_t slot = selectedWiFiSlot(s);
if (slot >= s.wifiSTANetworks.size() || s.wifiSTANetworks[slot].ssid.isEmpty()) {
if (_ui) _ui->lvStatusBar().showToast("WiFi profile already empty", 1200);
return;
}
s.wifiSTANetworks[slot].ssid = "";
s.wifiSTANetworks[slot].password = "";
applyAndSave();
if (_ui) _ui->lvStatusBar().showToast("WiFi profile cleared", 1200);
};
_items.push_back(forgetItem);
idx++;
}
{
SettingItem tcpPreset;
tcpPreset.label = "TCP Relay";
tcpPreset.type = SettingType::ENUM_CHOICE;
tcpPreset.getter = [&s]() {
for (auto& ep : s.tcpConnections) {
if (!ep.autoConnect || ep.host.isEmpty()) continue;
if (ep.host == "rns.ratspeak.org") return 1;
return 2;
}
return 0;
};
tcpPreset.setter = [&s](int v) {
if (v == 0) {
for (auto& ep : s.tcpConnections) ep.autoConnect = false;
}
else if (v == 1) {
s.tcpConnections.clear();
TCPEndpoint ep; ep.host = "rns.ratspeak.org"; ep.port = TCP_DEFAULT_PORT; ep.autoConnect = true;
s.tcpConnections.push_back(ep);
} else if (v == 2 && s.tcpConnections.empty()) {
TCPEndpoint ep; ep.port = TCP_DEFAULT_PORT; ep.autoConnect = false;
s.tcpConnections.push_back(ep);
}
};
tcpPreset.minVal = 0; tcpPreset.maxVal = 2; tcpPreset.step = 1;
tcpPreset.enumLabels = {"None", "Ratspeak Hub", "Custom"};
_items.push_back(tcpPreset);
idx++;
}
{
SettingItem tcpHost;
tcpHost.label = "Relay Host";
tcpHost.type = SettingType::TEXT_INPUT;
tcpHost.textGetter = [&s]() { return s.tcpConnections.empty() ? String("") : s.tcpConnections[0].host; };
tcpHost.textSetter = [&s](const String& v) {
if (s.tcpConnections.empty()) {
TCPEndpoint ep; ep.host = v; ep.port = TCP_DEFAULT_PORT; ep.autoConnect = true;
s.tcpConnections.push_back(ep);
} else { s.tcpConnections[0].host = v; }
};
tcpHost.maxTextLen = 40;
_items.push_back(tcpHost);
idx++;
}
_items.push_back({"Relay Port", SettingType::INTEGER,
[&s]() { return s.tcpConnections.empty() ? TCP_DEFAULT_PORT : (int)s.tcpConnections[0].port; },
[&s](int v) {
if (s.tcpConnections.empty()) {
TCPEndpoint ep; ep.port = v; ep.autoConnect = true;
s.tcpConnections.push_back(ep);
} else { s.tcpConnections[0].port = v; }
},
[](int v) { return String(v); }, 1, 65535, 1});
idx++;
_items.push_back({"LAN Discovery", SettingType::TOGGLE,
[&s]() { return s.autoIfaceEnabled ? 1 : 0; },
[&s](int v) { s.autoIfaceEnabled = (v != 0); },
[](int v) { return String(onOff(v != 0)); }});
idx++;
_categories.push_back({"Interfaces", netStart, idx - netStart,
[this, &s]() {
if (interfaceSettingsChanged() || tcpSettingsChanged()) return String("Saved - reboot to apply");
String summary = wifiModeLabel(s.wifiMode);
if (s.wifiMode == RAT_WIFI_STA) {
String ssid = WiFi.status() == WL_CONNECTED ? WiFi.SSID() : selectedWiFiSSID(s);
summary += ": ";
summary += ssid.isEmpty() ? String("No profile") : ssid;
}
if (!s.tcpConnections.empty()) summary += " + TCP";
if (s.autoIfaceEnabled) summary += " + LAN";
return summary;
}});
// Time & Location
int gpsStart = idx;
#if HAS_GPS
_items.push_back({"GPS Time Sync", SettingType::TOGGLE,
[&s]() { return s.gpsTimeEnabled ? 1 : 0; },
[&s](int v) { s.gpsTimeEnabled = (v != 0); },
[](int v) { return String(onOff(v != 0)); }});
idx++;
_items.push_back({"GPS Location", SettingType::TOGGLE,
[&s]() { return s.gpsLocationEnabled ? 1 : 0; },
[&s](int v) { s.gpsLocationEnabled = (v != 0); },
[](int v) { return String(onOff(v != 0)); }});
idx++;
#endif
_items.push_back({"Timezone", SettingType::INTEGER,
[&s]() { return (int)s.timezoneIdx; },
[&s](int v) { s.timezoneIdx = (uint8_t)v; s.timezoneSet = true; },
[](int v) {
if (v >= 0 && v < TIMEZONE_COUNT) return String(TIMEZONE_TABLE[v].label);
return String("Unknown");
},
0, TIMEZONE_COUNT - 1, 1});
idx++;
_items.push_back({"24-Hour Clock", SettingType::TOGGLE,
[&s]() { return s.use24HourTime ? 1 : 0; },
[&s](int v) { s.use24HourTime = (v != 0); },
[](int v) { return String(onOff(v != 0)); }});
idx++;
_categories.push_back({"Time & Location", gpsStart, idx - gpsStart,
[&s]() {
if (s.timezoneIdx < TIMEZONE_COUNT)
return String(TIMEZONE_TABLE[s.timezoneIdx].label);
return String("Not set");
}});
// Alerts & Audio
int audioStart = idx;
_items.push_back({"Audio Alerts", SettingType::TOGGLE,
[&s]() { return s.audioEnabled ? 1 : 0; },
[&s](int v) { s.audioEnabled = (v != 0); },
[](int v) { return String(onOff(v != 0)); }});
idx++;
_items.push_back({"Volume", SettingType::INTEGER,
[&s]() { return s.audioVolume; }, [&s](int v) { s.audioVolume = v; },
[](int v) { return String(v) + "%"; }, 0, 100, 10});
idx++;
_categories.push_back({"Alerts & Audio", audioStart, idx - audioStart,
[&s]() -> String {
if (!s.audioEnabled) return String("Muted");
String summary = String("Volume ") + String(s.audioVolume);
summary += "%";
return summary;
}});
// Diagnostics
int infoStart = idx;
_items.push_back({"RNS Transport", SettingType::READONLY, nullptr, nullptr,
[this](int) { return _rns && _rns->isTransportActive() ? String("ACTIVE") : String("OFFLINE"); }});
idx++;
_items.push_back({"Known Paths", SettingType::READONLY, nullptr, nullptr,
[this](int) { return _rns ? String((int)_rns->pathCount()) : String("0"); }});
idx++;
_items.push_back({"Live Links", SettingType::READONLY, nullptr, nullptr,
[this](int) { return _rns ? String((int)_rns->linkCount()) : String("0"); }});
idx++;
_items.push_back({"LoRa Driver", SettingType::READONLY, nullptr, nullptr,
[this](int) {
if (_radio && _radio->isRadioOnline()) {
char buf[32];
snprintf(buf, sizeof(buf), "SF%d BW%luk %ddBm",
_radio->getSpreadingFactor(),
(unsigned long)(_radio->getSignalBandwidth() / 1000),
_radio->getTxPower());
return String(buf);
}
return String("OFFLINE");
}});
idx++;
_items.push_back({"Heap", SettingType::READONLY, nullptr, nullptr,
[](int) { return String((unsigned long)(ESP.getFreeHeap() / 1024)) + " KB"; }});
idx++;
_items.push_back({"PSRAM", SettingType::READONLY, nullptr, nullptr,
[](int) { return String((unsigned long)(ESP.getFreePsram() / 1024)) + " KB"; }});
idx++;
_items.push_back({"Uptime", SettingType::READONLY, nullptr, nullptr,
[](int) -> String {
unsigned long m = millis() / 60000;
if (m >= 60) {
char buf[16];
snprintf(buf, sizeof(buf), "%luh %lum", m / 60, m % 60);
return String(buf);
}
return String(m) + "m";
}});
idx++;
_categories.push_back({"Diagnostics", infoStart, idx - infoStart,
[this]() -> String {
if (!_rns || !_rns->isTransportActive()) return String("Transport offline");
String summary = String("Paths ") + String((int)_rns->pathCount());
summary += " / Links ";
summary += String((int)_rns->linkCount());
return summary;
}});
// Storage & Maintenance
int sysStart = idx;
_items.push_back({"Free Heap", SettingType::READONLY, nullptr, nullptr,
[](int) { return String((unsigned long)(ESP.getFreeHeap() / 1024)) + " KB"; }});
idx++;
_items.push_back({"Free PSRAM", SettingType::READONLY, nullptr, nullptr,
[](int) { return String((unsigned long)(ESP.getFreePsram() / 1024)) + " KB"; }});
idx++;
_items.push_back({"Flash", SettingType::READONLY, nullptr, nullptr,
[this](int) {
if (!_flash || !_flash->isReady()) return String("Error");
char buf[24];
snprintf(buf, sizeof(buf), "%lu/%lu KB",
(unsigned long)(_flash->usedBytes() / 1024),
(unsigned long)(_flash->totalBytes() / 1024));
return String(buf);
}});
idx++;
_items.push_back({"SD Card", SettingType::READONLY, nullptr, nullptr,
[this](int) { return _sd && _sd->isReady() ? String("Ready") : String("Not Found"); }});
idx++;
_items.push_back({"SD Message Store", SettingType::TOGGLE,
[&s]() { return s.sdStorageEnabled ? 1 : 0; },
[&s](int v) { s.sdStorageEnabled = (v != 0); },
[](int v) { return String(onOff(v != 0)); }});
idx++;
{
SettingItem announceItem;
announceItem.label = "Send Announce";
announceItem.type = SettingType::ACTION;
announceItem.formatter = [](int) { return String("[Enter]"); };
announceItem.action = [this]() {
if (_rns && _cfg) {
RNS::Bytes appData = encodeAnnounceName(_cfg->settings().displayName);
_rns->announce(appData);
if (_ui) { _ui->lvStatusBar().flashAnnounce(); _ui->lvStatusBar().showToast("Announce sent"); }
} else {
if (_ui) _ui->lvStatusBar().showToast("RNS not ready");
}
};
_items.push_back(announceItem);
idx++;
}
{
SettingItem showQrItem;
showQrItem.label = "Show QR Code";
showQrItem.type = SettingType::ACTION;
showQrItem.formatter = [](int) { return String("[Enter]"); };
showQrItem.action = [this]() {
if (_showQrCb) _showQrCb();
else if (_ui) _ui->lvStatusBar().showToast("QR not available");
};
_items.push_back(showQrItem);
idx++;
}
{
SettingItem initSD;
initSD.label = "Format SD Card";
initSD.type = SettingType::ACTION;
initSD.formatter = [this](int) {
if (!_sd || !_sd->isReady()) return String("No Card");
return _confirmingInitSD ? String("Hold") : String("Arm");
};
initSD.action = [this]() {
if (!_sd || !_sd->isReady()) { if (_ui) _ui->lvStatusBar().showToast("No SD card!", 1200); return; }
if (!_confirmingInitSD) {
clearConfirmations();
_confirmingInitSD = true;
if (_ui) _ui->lvStatusBar().showToast("Format SD armed. Hold to confirm", 5000);
rebuildItemList();
return;
}
if (_ui) _ui->lvStatusBar().showToast("Hold trackball to format SD", 2500);
};
_items.push_back(initSD);
idx++;
}
{
SettingItem wipeSD;
wipeSD.label = "Erase rsDeck SD Data";
wipeSD.type = SettingType::ACTION;
wipeSD.formatter = [this](int) {
if (!_sd || !_sd->isReady()) return String("No Card");
return _confirmingWipeSD ? String("Hold") : String("Arm");
};
wipeSD.action = [this]() {
if (!_sd || !_sd->isReady()) { if (_ui) _ui->lvStatusBar().showToast("No SD card!", 1200); return; }
if (!_confirmingWipeSD) {
clearConfirmations();
_confirmingWipeSD = true;
if (_ui) _ui->lvStatusBar().showToast("SD erase armed. Hold to confirm", 5000);
rebuildItemList();
return;
}
if (_ui) _ui->lvStatusBar().showToast("Hold trackball to erase SD data", 2500);
};
_items.push_back(wipeSD);
idx++;
}
{
SettingItem factoryReset;
factoryReset.label = "Erase Device";
factoryReset.type = SettingType::ACTION;
factoryReset.formatter = [this](int) { return _confirmingReset ? String("Hold") : String("Arm"); };
factoryReset.action = [this]() {
if (!_confirmingReset) {
clearConfirmations();
_confirmingReset = true;
if (_ui) _ui->lvStatusBar().showToast("Device erase armed. Hold to confirm", 5000);
rebuildItemList();
return;
}
if (_ui) _ui->lvStatusBar().showToast("Hold trackball to erase device", 2500);
};
_items.push_back(factoryReset);
idx++;
}
{
SettingItem rebootItem;
rebootItem.label = "Reboot Now";
rebootItem.type = SettingType::ACTION;
rebootItem.formatter = [](int) { return String("[Enter]"); };
rebootItem.action = [this]() {
if (_ui) _ui->lvStatusBar().showToast("Rebooting...", 1500);
delay(500);
ESP.restart();
};
_items.push_back(rebootItem);
idx++;
}
{
SettingItem updateCheck;
updateCheck.label = "Check Firmware";
updateCheck.type = SettingType::ACTION;
updateCheck.formatter = [this](int) {
return _fwCheckState == FirmwareCheckState::RUNNING ? String("Checking") : String("[Enter]");
};
updateCheck.action = [this]() {
startFirmwareCheck();
rebuildItemList();
};
_items.push_back(updateCheck);
idx++;
}
_categories.push_back({"Storage & Maintenance", sysStart, idx - sysStart,
[this](){
if (_rebootNeeded) return String("Reboot pending");
return (_sd && _sd->isReady()) ? String("SD ready") : String("Flash only");
}});
}
void LvSettingsScreen::createUI(lv_obj_t* parent) {
_screen = parent;
lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_pad_all(parent, 0, 0);
_scrollContainer = lv_obj_create(parent);
lv_obj_set_size(_scrollContainer, lv_pct(100), lv_pct(100));
lv_obj_set_pos(_scrollContainer, 0, 0);
lv_obj_set_style_bg_color(_scrollContainer, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(_scrollContainer, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(_scrollContainer, 0, 0);
lv_obj_set_style_pad_all(_scrollContainer, 0, 0);
lv_obj_set_style_pad_row(_scrollContainer, 0, 0);
lv_obj_set_style_radius(_scrollContainer, 0, 0);
lv_obj_set_layout(_scrollContainer, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(_scrollContainer, LV_FLEX_FLOW_COLUMN);
}
void LvSettingsScreen::onEnter() {
buildItems();
snapshotRebootSettings();
snapshotTCPSettings();
_rebootNeeded = false;
_view = SettingsView::CATEGORY_LIST;
_categoryIdx = 0;
_selectedIdx = 0;
_editing = false;
_textEditing = false;
_wifiScanActive = false;
_wifiTargetSlot = 0;
clearConfirmations();
_kbBrightness = _cfg ? _cfg->settings().keyboardBrightness : 0;
rebuildCategoryList();
}
void LvSettingsScreen::refreshUI() {
FirmwareCheckState fwState = _fwCheckState;
if (fwState != FirmwareCheckState::IDLE && fwState != FirmwareCheckState::RUNNING) {
if (_ui) {
if (fwState == FirmwareCheckState::AVAILABLE) {
char msg[64];
snprintf(msg, sizeof(msg), "v%s available at ratspeak.org", _fwCheckVersion);
_ui->lvStatusBar().showToast(msg, 5000);
} else if (fwState == FirmwareCheckState::CURRENT) {
_ui->lvStatusBar().showToast("Firmware is current", 2000);
} else {
_ui->lvStatusBar().showToast("Couldn't fetch firmware", 2000);
}
}
_fwCheckState = FirmwareCheckState::IDLE;
if (_view == SettingsView::ITEM_LIST) rebuildItemList();
}
if (_view != SettingsView::WIFI_PICKER || !_wifiScanActive) return;
if (!WiFiInterface::isScanComplete()) return;
_wifiScanActive = false;
_wifiResults = WiFiInterface::getScanResults();
if (_wifiResults.empty() && _ui) {
_ui->lvStatusBar().showToast("No networks found", 1500);
}
rebuildWifiList();
}
void LvSettingsScreen::showWifiPicker() {
_wifiResults.clear();
_wifiPickerIdx = 0;
_wifiScanActive = true;
WiFiInterface::startAsyncScan();
_view = SettingsView::WIFI_PICKER;
rebuildWifiList();
}
void LvSettingsScreen::rebuildCategoryList() {
if (!_scrollContainer) return;
_rowObjs.clear();
lv_obj_clean(_scrollContainer);
const lv_font_t* font = &lv_font_rsdeck_12;
// Title
lv_obj_t* titleRow = lv_obj_create(_scrollContainer);
lv_obj_set_size(titleRow, Theme::CONTENT_W, 26);
lv_obj_set_style_bg_color(titleRow, lv_color_hex(Theme::BG_ELEVATED), 0);
lv_obj_set_style_bg_opa(titleRow, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(titleRow, lv_color_hex(Theme::BORDER), 0);
lv_obj_set_style_border_width(titleRow, 1, 0);
lv_obj_set_style_border_side(titleRow, LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_pad_all(titleRow, 0, 0);
lv_obj_set_style_radius(titleRow, 0, 0);
lv_obj_clear_flag(titleRow, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* titleLbl = lv_label_create(titleRow);
lv_obj_set_style_text_font(titleLbl, font, 0);
lv_obj_set_style_text_color(titleLbl, lv_color_hex(Theme::ACCENT), 0);
lv_label_set_text(titleLbl, "SETTINGS DECK");
lv_obj_align(titleLbl, LV_ALIGN_LEFT_MID, 8, 0);
if (_rebootNeeded) {
lv_obj_t* pendingLbl = lv_label_create(titleRow);
lv_obj_set_style_text_font(pendingLbl, &lv_font_rsdeck_10, 0);
lv_obj_set_style_text_color(pendingLbl, lv_color_hex(Theme::WARNING_CLR), 0);
lv_label_set_text(pendingLbl, "REBOOT PENDING");
lv_obj_align(pendingLbl, LV_ALIGN_RIGHT_MID, -8, 0);
}
for (int i = 0; i < (int)_categories.size(); i++) {
auto& cat = _categories[i];
bool selected = (i == _categoryIdx);
bool pending = categoryNeedsReboot(i);
lv_obj_t* row = lv_obj_create(_scrollContainer);
lv_obj_set_size(row, Theme::CONTENT_W, 40);
lv_obj_add_style(row, LvTheme::styleListBtn(), 0);
lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED);
lv_obj_set_style_bg_color(row, lv_color_hex(selected ? Theme::BG_HOVER : Theme::BG), 0);
lv_obj_set_style_border_color(row, lv_color_hex(pending ? Theme::WARNING_CLR : Theme::BORDER), 0);
lv_obj_set_style_border_width(row, pending ? 2 : 1, 0);
lv_obj_set_style_border_side(row, pending ? (lv_border_side_t)(LV_BORDER_SIDE_LEFT | LV_BORDER_SIDE_BOTTOM) : LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_user_data(row, (void*)(intptr_t)i);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
auto* self = (LvSettingsScreen*)lv_event_get_user_data(e);
int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
self->_categoryIdx = idx;
self->enterCategory(idx);
}, LV_EVENT_CLICKED, this);
lv_group_add_obj(LvInput::group(), row);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON);
}, LV_EVENT_FOCUSED, nullptr);
// Category name
lv_obj_t* nameLbl = lv_label_create(row);
lv_obj_set_style_text_font(nameLbl, font, 0);
lv_obj_set_style_text_color(nameLbl, lv_color_hex(pending ? Theme::WARNING_CLR : Theme::TEXT_PRIMARY), 0);
clipLabel(nameLbl, Theme::CONTENT_W - 72);
lv_label_set_text(nameLbl, cat.name);
lv_obj_align(nameLbl, LV_ALIGN_TOP_LEFT, 12, 4);
char countBuf[8];
snprintf(countBuf, sizeof(countBuf), "%d", cat.count);
lv_obj_t* countLbl = lv_label_create(row);
lv_obj_set_style_text_font(countLbl, &lv_font_rsdeck_10, 0);
lv_obj_set_style_text_color(countLbl, lv_color_hex(Theme::TEXT_MUTED), 0);
lv_label_set_text(countLbl, countBuf);
lv_obj_align(countLbl, LV_ALIGN_TOP_RIGHT, -24, 5);
// Summary
if (cat.summary) {
lv_obj_t* sumLbl = lv_label_create(row);
lv_obj_set_style_text_font(sumLbl, &lv_font_rsdeck_10, 0);
lv_obj_set_style_text_color(sumLbl, lv_color_hex(pending ? Theme::WARNING_CLR : Theme::TEXT_MUTED), 0);
clipLabel(sumLbl, Theme::CONTENT_W - 44);
lv_label_set_text(sumLbl, cat.summary().c_str());
lv_obj_align(sumLbl, LV_ALIGN_BOTTOM_LEFT, 20, -4);
}
// Arrow
lv_obj_t* arrow = lv_label_create(row);
lv_obj_set_style_text_font(arrow, font, 0);
lv_obj_set_style_text_color(arrow, lv_color_hex(pending ? Theme::WARNING_CLR : (selected ? Theme::ACCENT : Theme::TEXT_MUTED)), 0);
lv_label_set_text(arrow, pending ? "!" : ">");
lv_obj_align(arrow, LV_ALIGN_RIGHT_MID, -8, 0);
_rowObjs.push_back(row);
}
// Restore focus to current category
if (_categoryIdx >= 0 && _categoryIdx < (int)_rowObjs.size()) {
lv_group_focus_obj(_rowObjs[_categoryIdx]);
}
}
void LvSettingsScreen::rebuildItemList() {
if (!_scrollContainer) return;
_rowObjs.clear();
_editValueLbl = nullptr; // Invalidate cached label before destroying widgets
lv_obj_clean(_scrollContainer);
const lv_font_t* font = &lv_font_rsdeck_12;
// Category header
lv_obj_t* headerRow = lv_obj_create(_scrollContainer);
lv_obj_set_size(headerRow, Theme::CONTENT_W, 24);
lv_obj_set_style_bg_color(headerRow, lv_color_hex(Theme::BG_ELEVATED), 0);
lv_obj_set_style_bg_opa(headerRow, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(headerRow, lv_color_hex(Theme::BORDER), 0);
lv_obj_set_style_border_width(headerRow, 1, 0);
lv_obj_set_style_border_side(headerRow, LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_pad_all(headerRow, 0, 0);
lv_obj_set_style_radius(headerRow, 0, 0);
lv_obj_clear_flag(headerRow, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(headerRow, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(headerRow, [](lv_event_t* e) {
auto* self = (LvSettingsScreen*)lv_event_get_user_data(e);
self->exitToCategories();
}, LV_EVENT_CLICKED, this);
char headerBuf[48];
snprintf(headerBuf, sizeof(headerBuf), "< %s", _categories[_categoryIdx].name);
lv_obj_t* headerLbl = lv_label_create(headerRow);
lv_obj_set_style_text_font(headerLbl, font, 0);
lv_obj_set_style_text_color(headerLbl, lv_color_hex(Theme::ACCENT), 0);
clipLabel(headerLbl, Theme::CONTENT_W - 128);
lv_label_set_text(headerLbl, headerBuf);
lv_obj_align(headerLbl, LV_ALIGN_LEFT_MID, 8, 0);
if (_rebootNeeded) {
lv_obj_t* pendingLbl = lv_label_create(headerRow);
lv_obj_set_style_text_font(pendingLbl, &lv_font_rsdeck_10, 0);
lv_obj_set_style_text_color(pendingLbl, lv_color_hex(Theme::WARNING_CLR), 0);
lv_label_set_text(pendingLbl, "REBOOT NEEDED");
lv_obj_align(pendingLbl, LV_ALIGN_RIGHT_MID, -8, 0);
}
if (_rebootNeeded) {
lv_obj_t* notice = lv_obj_create(_scrollContainer);
lv_obj_set_size(notice, Theme::CONTENT_W, 20);
lv_obj_set_style_bg_color(notice, lv_color_hex(Theme::PRIMARY_SUBTLE), 0);
lv_obj_set_style_bg_opa(notice, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(notice, lv_color_hex(Theme::WARNING_CLR), 0);
lv_obj_set_style_border_width(notice, 1, 0);
lv_obj_set_style_border_side(notice, LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_pad_all(notice, 0, 0);
lv_obj_set_style_radius(notice, 0, 0);
lv_obj_clear_flag(notice, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* noticeLbl = lv_label_create(notice);
lv_obj_set_style_text_font(noticeLbl, &lv_font_rsdeck_10, 0);
lv_obj_set_style_text_color(noticeLbl, lv_color_hex(Theme::WARNING_CLR), 0);
clipLabel(noticeLbl, Theme::CONTENT_W - 16);
lv_label_set_text(noticeLbl, "Saved interface config is pending reboot");
lv_obj_align(noticeLbl, LV_ALIGN_LEFT_MID, 8, 0);
}
const char* confirmTitle = confirmationTitle();
const char* confirmDetail = confirmationDetail();
if (confirmTitle && confirmDetail) {
uint32_t confirmColor = _confirmingDevMode ? Theme::WARNING_CLR : Theme::ERROR_CLR;
lv_obj_t* confirm = lv_obj_create(_scrollContainer);
lv_obj_set_size(confirm, Theme::CONTENT_W, 34);
lv_obj_set_style_bg_color(confirm, lv_color_hex(Theme::BG_SURFACE), 0);
lv_obj_set_style_bg_opa(confirm, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(confirm, lv_color_hex(confirmColor), 0);
lv_obj_set_style_border_width(confirm, 2, 0);
lv_obj_set_style_border_side(confirm, (lv_border_side_t)(LV_BORDER_SIDE_LEFT | LV_BORDER_SIDE_BOTTOM), 0);
lv_obj_set_style_pad_all(confirm, 0, 0);
lv_obj_set_style_radius(confirm, 0, 0);
lv_obj_clear_flag(confirm, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* confirmTitleLbl = lv_label_create(confirm);
lv_obj_set_style_text_font(confirmTitleLbl, &lv_font_rsdeck_12, 0);
lv_obj_set_style_text_color(confirmTitleLbl, lv_color_hex(confirmColor), 0);
clipLabel(confirmTitleLbl, Theme::CONTENT_W - 16);
lv_label_set_text(confirmTitleLbl, confirmTitle);
lv_obj_align(confirmTitleLbl, LV_ALIGN_TOP_LEFT, 8, 3);
lv_obj_t* confirmDetailLbl = lv_label_create(confirm);
lv_obj_set_style_text_font(confirmDetailLbl, &lv_font_rsdeck_10, 0);
lv_obj_set_style_text_color(confirmDetailLbl, lv_color_hex(Theme::TEXT_PRIMARY), 0);
clipLabel(confirmDetailLbl, Theme::CONTENT_W - 16);
lv_label_set_text(confirmDetailLbl, confirmDetail);
lv_obj_align(confirmDetailLbl, LV_ALIGN_BOTTOM_LEFT, 8, -3);
}
for (int i = _catRangeStart; i < _catRangeEnd; i++) {
const auto& item = _items[i];
bool selected = (i == _selectedIdx);
bool editable = isEditable(i);
bool rebootPending = settingNeedsReboot(item);
bool armed = armedAction(item);
bool destructive = destructiveAction(item);
uint32_t armedColor = destructive ? Theme::ERROR_CLR : Theme::WARNING_CLR;
lv_obj_t* row = lv_obj_create(_scrollContainer);
lv_obj_set_size(row, Theme::CONTENT_W, 28);
lv_obj_add_style(row, LvTheme::styleListBtn(), 0);
if (editable) {
lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED);
}
lv_obj_set_style_bg_color(row, lv_color_hex(armed ? Theme::BG_SURFACE : (selected && editable ? Theme::BG_HOVER : Theme::BG)), 0);
lv_obj_set_style_border_color(row, lv_color_hex(armed ? armedColor : (rebootPending ? Theme::WARNING_CLR : Theme::BORDER)), 0);
lv_obj_set_style_border_width(row, rebootPending || armed ? 2 : 1, 0);
lv_obj_set_style_border_side(row, rebootPending || armed ? (lv_border_side_t)(LV_BORDER_SIDE_LEFT | LV_BORDER_SIDE_BOTTOM) : LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
if (editable) {
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_user_data(row, (void*)(intptr_t)i);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
auto* self = (LvSettingsScreen*)lv_event_get_user_data(e);
int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
if (self->_editing || self->_textEditing || self->_freqEditing) {
self->_editing = false;
self->_textEditing = false;
self->_freqEditing = false;
self->_numericTyping = false;
}
self->_selectedIdx = idx;
KeyEvent tap = {};
tap.enter = true;
self->handleKey(tap);
}, LV_EVENT_CLICKED, this);
lv_group_add_obj(LvInput::group(), row);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON);
}, LV_EVENT_FOCUSED, nullptr);
}
// Label
lv_obj_t* nameLbl = lv_label_create(row);
lv_obj_set_style_text_font(nameLbl, font, 0);
uint32_t nameColor =
armed ? armedColor :
rebootPending ? Theme::WARNING_CLR :
destructive ? Theme::ERROR_CLR :
item.type == SettingType::ACTION ? Theme::TEXT_PRIMARY :
item.type == SettingType::READONLY ? Theme::TEXT_MUTED : Theme::TEXT_SECONDARY;
lv_obj_set_style_text_color(nameLbl, lv_color_hex(nameColor), 0);
clipLabel(nameLbl, Theme::CONTENT_W - 136);
lv_label_set_text(nameLbl, item.label);
lv_obj_align(nameLbl, LV_ALIGN_LEFT_MID, 8, 0);
// Value
String valStr;
uint32_t valColor = Theme::PRIMARY;
if (_freqEditing && selected) {
valStr = String("< ") + freqFormatWithCursor() + " >";
valColor = Theme::WARNING_CLR;
} else if (_editing && selected) {
if (item.type == SettingType::ENUM_CHOICE && !item.enumLabels.empty()) {
int vi = constrain(_editValue, 0, (int)item.enumLabels.size() - 1);
valStr = String("< ") + item.enumLabels[vi] + " >";
} else if (item.formatter) {
valStr = String("< ") + item.formatter(_editValue) + " >";
} else {
valStr = String("< ") + String(_editValue) + " >";
}
valColor = Theme::WARNING_CLR;
} else if (_textEditing && selected) {
valStr = _editText + "_";
valColor = Theme::WARNING_CLR;
} else {
switch (item.type) {
case SettingType::READONLY:
valStr = item.formatter ? item.formatter(0) : "";
valColor = Theme::TEXT_MUTED;
break;
case SettingType::TEXT_INPUT: {
String v = item.textGetter ? item.textGetter() : "";
valStr = v.isEmpty() ? "(not set)" : (isWiFiPasswordLabel(item.label) ? maskedValue(v) : v);
valColor = v.isEmpty() ? Theme::TEXT_MUTED : Theme::PRIMARY;
break;
}
case SettingType::ENUM_CHOICE:
if (!item.enumLabels.empty()) {
int vi = item.getter ? constrain(item.getter(), 0, (int)item.enumLabels.size() - 1) : 0;
valStr = item.enumLabels[vi];
}
break;
case SettingType::ACTION:
valStr = item.formatter ? item.formatter(0) : "";
valColor = destructive ? Theme::ERROR_CLR : Theme::TEXT_MUTED;
break;
default: {
int v = item.getter ? item.getter() : 0;
valStr = item.formatter ? item.formatter(v) : String(v);
break;
}
}
}
if (armed) {
valColor = armedColor;
} else if (rebootPending) {
valColor = Theme::WARNING_CLR;
}
if (!valStr.isEmpty()) {
lv_obj_t* valLbl = lv_label_create(row);
lv_obj_set_style_text_font(valLbl, font, 0);
lv_obj_set_style_text_color(valLbl, lv_color_hex(valColor), 0);
lv_obj_set_style_text_align(valLbl, LV_TEXT_ALIGN_RIGHT, 0);
clipLabel(valLbl, 124);
lv_label_set_text(valLbl, valStr.c_str());
lv_obj_align(valLbl, LV_ALIGN_RIGHT_MID, -8, 0);
// Cache value label for the actively edited item (in-place updates)
if (i == _selectedIdx && (_textEditing || _freqEditing || _editing)) {
_editValueLbl = valLbl;
}
}
_rowObjs.push_back(row);
}
// Restore focus to the currently selected item after rebuild
int focusOffset = _selectedIdx - _catRangeStart;
if (focusOffset >= 0 && focusOffset < (int)_rowObjs.size()) {
lv_group_focus_obj(_rowObjs[focusOffset]);
}
}
void LvSettingsScreen::selectWifiResult(int resultIdx) {
if (!_cfg || resultIdx < 0 || resultIdx >= (int)_wifiResults.size()) return;
auto& net = _wifiResults[resultIdx];
auto& nets = _cfg->settings().wifiSTANetworks;
while (nets.size() <= _wifiTargetSlot && nets.size() < WIFI_STA_MAX_NETWORKS) {
nets.push_back({});
}
if (_wifiTargetSlot >= WIFI_STA_MAX_NETWORKS || _wifiTargetSlot >= nets.size()) {
if (_ui) _ui->lvStatusBar().showToast("Network slots full", 1500);
return;
}
for (size_t i = 0; i < nets.size(); i++) {
if (i != _wifiTargetSlot && !nets[i].ssid.isEmpty() && nets[i].ssid == net.ssid) {
if (_ui) _ui->lvStatusBar().showToast("Already saved", 1200);
return;
}
}
bool sameSSID = nets[_wifiTargetSlot].ssid == net.ssid;
nets[_wifiTargetSlot].ssid = net.ssid;
if (!sameSSID) nets[_wifiTargetSlot].password = "";
_cfg->settings().wifiSTASelected = (uint8_t)_wifiTargetSlot;
applyAndSave();
}
void LvSettingsScreen::rebuildWifiList() {
if (!_scrollContainer) return;
_rowObjs.clear();
lv_obj_clean(_scrollContainer);
const lv_font_t* font = &lv_font_rsdeck_12;
// Header
lv_obj_t* headerRow = lv_obj_create(_scrollContainer);
lv_obj_set_size(headerRow, Theme::CONTENT_W, 22);
lv_obj_set_style_bg_opa(headerRow, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_color(headerRow, lv_color_hex(Theme::BORDER), 0);
lv_obj_set_style_border_width(headerRow, 1, 0);
lv_obj_set_style_border_side(headerRow, LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_pad_all(headerRow, 0, 0);
lv_obj_set_style_radius(headerRow, 0, 0);
lv_obj_clear_flag(headerRow, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* headerLbl = lv_label_create(headerRow);
lv_obj_set_style_text_font(headerLbl, font, 0);
lv_obj_set_style_text_color(headerLbl, lv_color_hex(Theme::ACCENT), 0);
char header[48];
snprintf(header, sizeof(header), "< Scan for profile %u", (unsigned)(_wifiTargetSlot + 1));
lv_label_set_text(headerLbl, header);
lv_obj_align(headerLbl, LV_ALIGN_LEFT_MID, 4, 0);
// Make header tappable to go back
lv_obj_add_flag(headerRow, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(headerRow, [](lv_event_t* e) {
auto* self = (LvSettingsScreen*)lv_event_get_user_data(e);
self->_wifiScanActive = false;
self->_view = SettingsView::ITEM_LIST;
self->rebuildItemList();
}, LV_EVENT_CLICKED, this);
if (_wifiResults.empty()) {
lv_obj_t* emptyLbl = lv_label_create(_scrollContainer);
lv_obj_set_style_text_font(emptyLbl, font, 0);
lv_obj_set_style_text_color(emptyLbl, lv_color_hex(Theme::TEXT_MUTED), 0);
lv_label_set_text(emptyLbl, _wifiScanActive ? "Scanning..." : "No networks found");
return;
}
for (int i = 0; i < (int)_wifiResults.size(); i++) {
auto& net = _wifiResults[i];
lv_obj_t* row = lv_obj_create(_scrollContainer);
lv_obj_set_size(row, Theme::CONTENT_W, 22);
lv_obj_add_style(row, LvTheme::styleListBtn(), 0);
lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED);
lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(row, 0, 0);
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_user_data(row, (void*)(intptr_t)i);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
auto* self = (LvSettingsScreen*)lv_event_get_user_data(e);
int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
self->selectWifiResult(idx);
self->_wifiScanActive = false;
self->_view = SettingsView::ITEM_LIST;
self->rebuildItemList();
}, LV_EVENT_CLICKED, this);
lv_group_add_obj(LvInput::group(), row);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON);
}, LV_EVENT_FOCUSED, nullptr);
// Lock + SSID
char buf[48];
snprintf(buf, sizeof(buf), "%s %s", net.encrypted ? "*" : " ", net.ssid.c_str());
lv_obj_t* lbl = lv_label_create(row);
lv_obj_set_style_text_font(lbl, font, 0);
lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::PRIMARY), 0);
lv_label_set_text(lbl, buf);
lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0);
// Signal
char sigBuf[12];
snprintf(sigBuf, sizeof(sigBuf), "%ddBm", net.rssi);
lv_obj_t* sigLbl = lv_label_create(row);
lv_obj_set_style_text_font(sigLbl, &lv_font_rsdeck_10, 0);
lv_obj_set_style_text_color(sigLbl, lv_color_hex(Theme::TEXT_MUTED), 0);
lv_label_set_text(sigLbl, sigBuf);
lv_obj_align(sigLbl, LV_ALIGN_RIGHT_MID, -4, 0);
_rowObjs.push_back(row);
}
}
void LvSettingsScreen::updateCategorySelection(int oldIdx, int newIdx) {
if (oldIdx >= 0 && oldIdx < (int)_rowObjs.size()) {
bool pending = categoryNeedsReboot(oldIdx);
lv_obj_set_style_bg_color(_rowObjs[oldIdx], lv_color_hex(Theme::BG), 0);
lv_obj_t* nameLbl = lv_obj_get_child(_rowObjs[oldIdx], 0);
if (nameLbl) lv_obj_set_style_text_color(nameLbl, lv_color_hex(pending ? Theme::WARNING_CLR : Theme::TEXT_PRIMARY), 0);
lv_obj_t* arrow = lv_obj_get_child(_rowObjs[oldIdx], -1);
if (arrow) lv_obj_set_style_text_color(arrow, lv_color_hex(pending ? Theme::WARNING_CLR : Theme::TEXT_MUTED), 0);
}
if (newIdx >= 0 && newIdx < (int)_rowObjs.size()) {
bool pending = categoryNeedsReboot(newIdx);
lv_obj_set_style_bg_color(_rowObjs[newIdx], lv_color_hex(Theme::BG_HOVER), 0);
lv_obj_t* nameLbl = lv_obj_get_child(_rowObjs[newIdx], 0);
if (nameLbl) lv_obj_set_style_text_color(nameLbl, lv_color_hex(pending ? Theme::WARNING_CLR : Theme::TEXT_PRIMARY), 0);
lv_obj_t* arrow = lv_obj_get_child(_rowObjs[newIdx], -1);
if (arrow) lv_obj_set_style_text_color(arrow, lv_color_hex(pending ? Theme::WARNING_CLR : Theme::PRIMARY), 0);
lv_obj_scroll_to_view(_rowObjs[newIdx], LV_ANIM_OFF);
}
}
void LvSettingsScreen::updateItemSelection(int oldIdx, int newIdx) {
// _rowObjs maps directly to items (header row is NOT in _rowObjs)
int oldRow = oldIdx - _catRangeStart;
int newRow = newIdx - _catRangeStart;
auto setItemRowBg = [&](int row, bool selected) {
if (row < 0 || row >= (int)_rowObjs.size()) return;
int itemIdx = row + _catRangeStart;
bool editable = isEditable(itemIdx);
const auto& item = _items[itemIdx];
bool armed = armedAction(item);
lv_obj_set_style_bg_color(_rowObjs[row], lv_color_hex(
armed ? Theme::BG_SURFACE : ((selected && editable) ? Theme::BG_HOVER : Theme::BG)), 0);
};
setItemRowBg(oldRow, false);
setItemRowBg(newRow, true);
if (newRow >= 0 && newRow < (int)_rowObjs.size()) {
lv_obj_scroll_to_view(_rowObjs[newRow], LV_ANIM_OFF);
}
}
void LvSettingsScreen::updateWifiSelection(int oldIdx, int newIdx) {
if (oldIdx >= 0 && oldIdx < (int)_rowObjs.size()) {
lv_obj_set_style_bg_color(_rowObjs[oldIdx], lv_color_hex(Theme::BG), 0);
lv_obj_t* lbl = lv_obj_get_child(_rowObjs[oldIdx], 0);
if (lbl) lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::TEXT_PRIMARY), 0);
}
if (newIdx >= 0 && newIdx < (int)_rowObjs.size()) {
lv_obj_set_style_bg_color(_rowObjs[newIdx], lv_color_hex(Theme::BG_HOVER), 0);
lv_obj_t* lbl = lv_obj_get_child(_rowObjs[newIdx], 0);
if (lbl) lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::TEXT_PRIMARY), 0);
lv_obj_scroll_to_view(_rowObjs[newIdx], LV_ANIM_OFF);
}
}
void LvSettingsScreen::enterCategory(int catIdx) {
if (catIdx < 0 || catIdx >= (int)_categories.size()) return;
_categoryIdx = catIdx;
auto& cat = _categories[catIdx];
_catRangeStart = cat.startIdx;
_catRangeEnd = cat.startIdx + cat.count;
_selectedIdx = _catRangeStart;
_editing = false;
_textEditing = false;
if (!isEditable(_selectedIdx)) skipToNextEditable(1);
_view = SettingsView::ITEM_LIST;
rebuildItemList();
}
void LvSettingsScreen::exitToCategories() {
_view = SettingsView::CATEGORY_LIST;
_editing = false;
_textEditing = false;
clearConfirmations();
rebuildCategoryList();
}
bool LvSettingsScreen::handleKey(const KeyEvent& event) {
switch (_view) {
case SettingsView::CATEGORY_LIST: {
// LVGL focus group handles up/down navigation
if (event.enter || event.character == '\n' || event.character == '\r') {
// Get focused category from LVGL group
lv_obj_t* focused = lv_group_get_focused(LvInput::group());
if (focused) _categoryIdx = (int)(intptr_t)lv_obj_get_user_data(focused);
enterCategory(_categoryIdx);
return true;
}
return false;
}
case SettingsView::ITEM_LIST: {
if (_items.empty()) return false;
// Text edit mode - in-place label updates for responsiveness
if (_textEditing) {
auto& item = _items[_selectedIdx];
auto updateEditLabel = [this, &item]() {
if (_editValueLbl) {
String display = _editText + "_";
lv_label_set_text(_editValueLbl, display.c_str());
lv_obj_align(_editValueLbl, LV_ALIGN_RIGHT_MID, -8, 0);
}
};
if (event.enter || event.character == '\n' || event.character == '\r') {
if (item.textSetter) item.textSetter(_editText);
_textEditing = false;
_editValueLbl = nullptr;
applyAndSave();
rebuildItemList();
return true;
}
if (event.del || event.character == 8) {
if (_editText.length() > 0) { _editText.remove(_editText.length() - 1); updateEditLabel(); }
return true;
}
if (event.character == 0x1B) { _textEditing = false; _editValueLbl = nullptr; rebuildItemList(); return true; }
if (event.character >= 0x20 && event.character <= 0x7E && (int)_editText.length() < item.maxTextLen) {
_editText += (char)event.character; updateEditLabel(); return true;
}
return true;
}
// Frequency digit-cursor edit mode - in-place label updates
if (_freqEditing) {
auto updateFreqLabel = [this]() {
if (_editValueLbl) {
String display = String("< ") + freqFormatWithCursor() + " >";
lv_label_set_text(_editValueLbl, display.c_str());
lv_obj_align(_editValueLbl, LV_ALIGN_RIGHT_MID, -8, 0);
}
};
if (event.left) {
if (_freqCursor > 0) _freqCursor--;
updateFreqLabel(); return true;
}
if (event.right) {
if (_freqCursor < 8) _freqCursor++;
updateFreqLabel(); return true;
}
if (event.character >= '0' && event.character <= '9') {
_freqDigits[_freqCursor] = event.character - '0';
_editValue = freqRecompose();
if (_freqCursor < 8) _freqCursor++;
updateFreqLabel(); return true;
}
if (event.enter || event.character == '\n' || event.character == '\r') {
auto& item = _items[_selectedIdx];
_editValue = freqRecompose();
if (item.setter) item.setter(_editValue);
_freqEditing = false; _editing = false;
_editValueLbl = nullptr;
applyAndSave(); rebuildItemList(); return true;
}
if (event.del || event.character == 8) {
if (_freqCursor > 0) _freqCursor--;
updateFreqLabel(); return true;
}
if (event.character == 0x1B) {
_editValue = _freqOriginal;
_freqEditing = false; _editing = false;
_editValueLbl = nullptr;
rebuildItemList(); return true;
}
return true;
}
// Value edit mode
if (_editing) {
auto& item = _items[_selectedIdx];
// Direct digit entry for INTEGER fields
if (event.character >= '0' && event.character <= '9') {
int digit = event.character - '0';
if (!_numericTyping) {
_editValue = digit;
_numericTyping = true;
} else {
int newVal = _editValue * 10 + digit;
if (newVal <= item.maxVal) _editValue = newVal;
}
rebuildItemList(); return true;
}
if (event.left) {
_editValue -= item.step;
if (_editValue < item.minVal) _editValue = item.minVal;
_numericTyping = false;
rebuildItemList(); return true;
}
if (event.right) {
_editValue += item.step;
if (_editValue > item.maxVal) _editValue = item.maxVal;
_numericTyping = false;
rebuildItemList(); return true;
}
if (event.enter || event.character == '\n' || event.character == '\r') {
if (_editValue < item.minVal) _editValue = item.minVal;
if (item.setter) item.setter(_editValue);
_editing = false;
_numericTyping = false;
applyAndSave();
rebuildItemList(); return true;
}
if (event.del || event.character == 8) {
if (_numericTyping && _editValue > 0) {
_editValue /= 10;
} else {
_editing = false;
_numericTyping = false;
}
rebuildItemList(); return true;
}
if (event.character == 0x1B) {
_editing = false; _numericTyping = false; rebuildItemList(); return true;
}
return true;
}
// Browse mode - LVGL focus group handles up/down
if (hasPendingConfirmation() && (event.up || event.down || event.left || event.right || event.tab)) {
clearConfirmations();
rebuildItemList();
if (_ui) _ui->lvStatusBar().showToast("Confirmation cancelled", 1000);
return true;
}
if (event.del || event.character == 8 || event.character == 0x1B) {
if (hasPendingConfirmation()) {
clearConfirmations();
rebuildItemList();
if (_ui) _ui->lvStatusBar().showToast("Confirmation cancelled", 1000);
return true;
}
exitToCategories(); return true;
}
if (event.enter || event.character == '\n' || event.character == '\r') {
// Sync _selectedIdx from LVGL focus
lv_obj_t* focused = lv_group_get_focused(LvInput::group());
if (focused) _selectedIdx = (int)(intptr_t)lv_obj_get_user_data(focused);
if (!isEditable(_selectedIdx)) return true;
auto& item = _items[_selectedIdx];
if (item.type == SettingType::ACTION) {
if (!confirmableAction(item)) clearConfirmations();
if (item.action) item.action();
if (_view == SettingsView::ITEM_LIST) rebuildItemList();
} else if (item.type == SettingType::TEXT_INPUT) {
clearConfirmations();
_textEditing = true;
_editText = item.textGetter ? item.textGetter() : "";
rebuildItemList();
} else if (item.type == SettingType::TOGGLE) {
clearConfirmations();
int val = item.getter ? item.getter() : 0;
if (item.setter) item.setter(val ? 0 : 1);
applyAndSave();
rebuildItemList();
} else if (strcmp(item.label, "Frequency") == 0 && item.type == SettingType::INTEGER) {
clearConfirmations();
// Radio-style digit cursor editor for frequency
_editing = true;
_editValue = item.getter ? item.getter() : 0;
_freqOriginal = _editValue;
freqDecompose(_editValue);
_freqCursor = 0;
_freqEditing = true;
rebuildItemList();
} else {
clearConfirmations();
_editing = true;
_numericTyping = false;
_editValue = item.getter ? item.getter() : 0;
rebuildItemList();
}
return true;
}
return false;
}
case SettingsView::WIFI_PICKER: {
// LVGL handles up/down navigation, click handler handles selection
if (event.enter || event.character == '\n' || event.character == '\r') {
if (_wifiScanActive) return true;
lv_obj_t* focused = lv_group_get_focused(LvInput::group());
if (focused) {
int idx = (int)(intptr_t)lv_obj_get_user_data(focused);
if (idx < (int)_wifiResults.size()) {
selectWifiResult(idx);
}
}
_wifiScanActive = false;
_view = SettingsView::ITEM_LIST;
rebuildItemList();
return true;
}
if (event.del || event.character == 8 || event.character == 0x1B) {
_wifiScanActive = false;
_view = SettingsView::ITEM_LIST;
rebuildItemList();
return true;
}
return false;
}
}
return false;
}
bool LvSettingsScreen::handleLongPress() {
if (_view != SettingsView::ITEM_LIST || !hasPendingConfirmation()) return false;
int focusedIdx = _selectedIdx;
lv_obj_t* focused = lv_group_get_focused(LvInput::group());
if (focused) {
int candidate = (int)(intptr_t)lv_obj_get_user_data(focused);
if (candidate >= 0 && candidate < (int)_items.size()) focusedIdx = candidate;
}
if (focusedIdx < 0 || focusedIdx >= (int)_items.size()) return false;
const auto& item = _items[focusedIdx];
if (_confirmingInitSD && labelEq(item.label, "Format SD Card")) {
runFormatSD();
return true;
}
if (_confirmingWipeSD && labelEq(item.label, "Erase rsDeck SD Data")) {
runWipeSD();
return true;
}
if (_confirmingReset && labelEq(item.label, "Erase Device")) {
runFactoryReset();
return true;
}
if (_confirmingDevMode && labelEq(item.label, "Developer Radio Controls")) {
runEnableDevMode();
return true;
}
if (_ui) _ui->lvStatusBar().showToast("Select armed action, then hold", 2000);
return true;
}
void LvSettingsScreen::snapshotRebootSettings() {
if (!_cfg) return;
auto& s = _cfg->settings();
_rebootSnap.wifiMode = s.wifiMode;
_rebootSnap.wifiSTANetworks = s.wifiSTANetworks;
_rebootSnap.wifiSTASelected = s.wifiSTASelected;
_rebootSnap.autoIfaceEnabled = s.autoIfaceEnabled;
_rebootSnap.sdStorageEnabled = s.sdStorageEnabled;
_rebootSnap.loraEnabled = s.loraEnabled;
_gpsSnapEnabled = s.gpsTimeEnabled;
}
bool LvSettingsScreen::rebootSettingsChanged() const {
return loraSettingsChanged() || interfaceSettingsChanged()
|| storageSettingsChanged() || tcpSettingsChanged();
}
bool LvSettingsScreen::loraSettingsChanged() const {
if (!_cfg) return false;
return _cfg->settings().loraEnabled != _rebootSnap.loraEnabled;
}
bool LvSettingsScreen::interfaceSettingsChanged() const {
if (!_cfg) return false;
const auto& s = _cfg->settings();
if (s.wifiMode != _rebootSnap.wifiMode) return true;
if (s.wifiSTASelected != _rebootSnap.wifiSTASelected) return true;
if (s.autoIfaceEnabled != _rebootSnap.autoIfaceEnabled) return true;
if (s.wifiSTANetworks.size() != _rebootSnap.wifiSTANetworks.size()) return true;
for (size_t i = 0; i < s.wifiSTANetworks.size(); i++) {
if (s.wifiSTANetworks[i].ssid != _rebootSnap.wifiSTANetworks[i].ssid) return true;
if (s.wifiSTANetworks[i].password != _rebootSnap.wifiSTANetworks[i].password) return true;
}
return false;
}
bool LvSettingsScreen::storageSettingsChanged() const {
if (!_cfg) return false;
return _cfg->settings().sdStorageEnabled != _rebootSnap.sdStorageEnabled;
}
void LvSettingsScreen::snapshotTCPSettings() {
if (!_cfg) return;
auto& s = _cfg->settings();
_tcpSnapHost = s.tcpConnections.empty() ? "" : s.tcpConnections[0].host;
_tcpSnapPort = s.tcpConnections.empty() ? 0 : s.tcpConnections[0].port;
_tcpSnapAuto = !s.tcpConnections.empty() && s.tcpConnections[0].autoConnect;
}
bool LvSettingsScreen::tcpSettingsChanged() const {
if (!_cfg) return false;
auto& s = _cfg->settings();
String curHost = s.tcpConnections.empty() ? "" : s.tcpConnections[0].host;
uint16_t curPort = s.tcpConnections.empty() ? 0 : s.tcpConnections[0].port;
bool curAuto = !s.tcpConnections.empty() && s.tcpConnections[0].autoConnect;
return curHost != _tcpSnapHost || curPort != _tcpSnapPort || curAuto != _tcpSnapAuto;
}
// --- Frequency digit-cursor editor helpers ---
void LvSettingsScreen::freqDecompose(int value) {
// Decompose Hz value into 9 individual digits (left-padded with zeros)
for (int i = 8; i >= 0; i--) {
_freqDigits[i] = value % 10;
value /= 10;
}
}
int LvSettingsScreen::freqRecompose() const {
int val = 0;
for (int i = 0; i < 9; i++) val = val * 10 + _freqDigits[i];
return val;
}
String LvSettingsScreen::freqFormatWithCursor() const {
// Format as "NNN.NNN.NNN" with brackets around cursor digit
char buf[24];
char digits[9];
for (int i = 0; i < 9; i++) digits[i] = '0' + _freqDigits[i];
// Build string with cursor brackets: e.g., "920.[6]50.500"
int pos = 0;
for (int i = 0; i < 9; i++) {
if (i == 3 || i == 6) buf[pos++] = '.';
if (i == _freqCursor) {
buf[pos++] = '[';
buf[pos++] = digits[i];
buf[pos++] = ']';
} else {
buf[pos++] = digits[i];
}
}
buf[pos] = '\0';
return String(buf);
}
void LvSettingsScreen::applyAndSave() {
if (!_cfg) return;
auto& s = _cfg->settings();
// Theme switch applies live: palette globals -> shared styles -> shell.
// Our own rows re-read Theme:: on the rebuild that follows every commit.
Theme::Scheme want = s.themeLight ? Theme::Scheme::LIGHT : Theme::Scheme::DARK;
if (want != Theme::scheme()) {
Theme::setScheme(want);
if (_ui) _ui->applyTheme();
if (_screen) lv_obj_set_style_bg_color(_screen, lv_color_hex(Theme::BG), 0);
if (_scrollContainer) lv_obj_set_style_bg_color(_scrollContainer, lv_color_hex(Theme::BG), 0);
}
if (_power) {
_power->setBrightness(s.brightness);
_power->setDimTimeout(s.screenDimTimeout);
_power->setOffTimeout(s.screenOffTimeout);
if (_kbBrightness != s.keyboardBrightness) {
_power->setKbBrightness(s.keyboardBrightness, true);
_kbBrightness = s.keyboardBrightness;
}
_power->setKbAutoOn(s.keyboardAutoOn);
_power->setKbAutoOff(s.keyboardAutoOff);
}
if (_radio && _radio->isRadioOnline()) {
if (s.loraEnabled) {
_radio->setFrequency(s.loraFrequency);
_radio->setTxPower(s.loraTxPower);
_radio->setSpreadingFactor(s.loraSF);
_radio->setSignalBandwidth(s.loraBW);
_radio->setCodingRate4(s.loraCR);
_radio->setPreambleLength(s.loraPreamble);
_radio->receive();
} else {
_radio->sleep();
}
}
if (_audio) {
_audio->setEnabled(s.audioEnabled);
_audio->setVolume(s.audioVolume);
}
// Detect TCP server change before saving
bool tcpChanged = tcpSettingsChanged();
bool saved = false;
if (_saveCallback) { saved = _saveCallback(); }
else if (_sd && _flash) { saved = _cfg->save(*_sd, *_flash); }
else if (_flash) { saved = _cfg->save(*_flash); }
// TCP relay changes are persisted only. Recreating clients live can race
// in-flight sockets/netif teardown on ESP32; reboot applies them cleanly.
// Apply GPS toggle live (start/stop GPS UART)
if (s.gpsTimeEnabled != _gpsSnapEnabled) {
_gpsSnapEnabled = s.gpsTimeEnabled;
if (_gpsChangeCb) _gpsChangeCb(s.gpsTimeEnabled);
}
// Check if reboot-required settings changed (show toast only on first detection)
bool wasRebootNeeded = _rebootNeeded;
_rebootNeeded = rebootSettingsChanged();
if (_ui) {
if (!saved) {
_ui->lvStatusBar().showToast("Save failed", 2000);
} else if (_rebootNeeded && !wasRebootNeeded) {
_ui->lvStatusBar().showToast(
tcpChanged ? "TCP relay saved; reboot to apply" : "Interface changes saved; reboot to apply",
3000);
} else if (!_rebootNeeded && wasRebootNeeded) {
_ui->lvStatusBar().showToast("Pending reboot cleared", 1500);
} else if (tcpChanged) {
_ui->lvStatusBar().showToast("TCP relay saved; reboot to apply", 3000);
} else {
_ui->lvStatusBar().showToast("Saved", 800);
}
}
}