forked from ratspeak/rsCardputer
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSettingsScreen.cpp
More file actions
1710 lines (1536 loc) · 60.5 KB
/
Copy pathSettingsScreen.cpp
File metadata and controls
1710 lines (1536 loc) · 60.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#include "SettingsScreen.h"
#include "ui/Theme.h"
#include "config/Config.h"
#include "security/Duress.h"
#include "storage/FactoryWipe.h"
#include "reticulum/IdentityCrypto.h"
#include <algorithm>
#include <cctype>
#include <WiFi.h>
#include <WiFiClient.h>
// Strict: exactly 6 hex digits, nothing else. Used to drive the live color
// swatch while editing a Theme > Custom field — only renders once the typed
// text is a complete, valid hex value.
static bool parseHex6(const std::string& s, uint8_t& r, uint8_t& g, uint8_t& b) {
if (s.size() != 6) return false;
for (char c : s) {
if (!isxdigit((unsigned char)c)) return false;
}
long v = strtol(s.c_str(), nullptr, 16);
r = (uint8_t)((v >> 16) & 0xFF);
g = (uint8_t)((v >> 8) & 0xFF);
b = (uint8_t)(v & 0xFF);
return true;
}
// Single source of truth for the Theme > Custom field list: display order,
// label, and which Theme::BaseColors member each row actually edits.
// `index` matches the field argument used throughout this file (startMixer,
// commitEdit, getCurrentValue, commitMixer) — those stay in BaseColors'
// declaration order (bg, text, primary, secondary, accent, error, warning);
// only the on-screen order/labels are reshuffled here, in one place, so menu
// order and field plumbing can never drift out of sync with each other.
// Grouped by what a user is actually looking at: surface/text colors first,
// then the two "emphasis" colors (selection vs. screen-header chrome), then
// status colors in ascending severity (warn before error).
struct ThemeFieldInfo { int index; const char* label; };
static const ThemeFieldInfo kThemeFields[7] = {
{0, "Background"}, // bg
{1, "Text"}, // text
{3, "Subtext"}, // secondary
{2, "Highlight"}, // primary
{4, "Header"}, // accent
{6, "Warning"}, // warning
{5, "Error"}, // error
};
static std::string hexForBaseField(const Theme::BaseColors& c, int fieldIndex) {
switch (fieldIndex) {
case 0: return std::string(c.bg.c_str());
case 1: return std::string(c.text.c_str());
case 2: return std::string(c.primary.c_str());
case 3: return std::string(c.secondary.c_str());
case 4: return std::string(c.accent.c_str());
case 5: return std::string(c.error.c_str());
case 6: return std::string(c.warning.c_str());
default: return std::string();
}
}
static const char* labelForBaseField(int fieldIndex) {
for (const auto& f : kThemeFields) {
if (f.index == fieldIndex) return f.label;
}
return "Color";
}
void SettingsScreen::onEnter() {
_subMenu = MENU_MAIN;
_editing = false;
_editField = -1;
buildMainMenu();
}
void SettingsScreen::buildMainMenu() {
_list.clear();
_list.addItem("Radio");
_list.addItem("Wi-Fi");
_list.addItem("Display");
_list.addItem("Audio");
_list.addItem("Time");
_list.addItem("Security");
_list.addItem("Messaging");
_list.addItem("SD Card");
_list.addItem("About");
_list.addItem("Factory Reset", Theme::ERROR);
}
// LXMF anti-spam stamp settings — see docs/lxmf-stamps.md.
void SettingsScreen::buildMessagingMenu() {
_list.clear();
if (!_config) return;
auto& s = _config->settings();
char buf[48];
snprintf(buf, sizeof(buf), "Stamp Cost Ceiling: %d", s.stampCostCeiling);
_list.addItem(buf);
_list.addItem("< Back");
}
void SettingsScreen::buildTimeMenu() {
_list.clear();
if (!_config) return;
auto& s = _config->settings();
_list.addItem(s.manualTimezoneEnabled ? "Time Source: Manual" : "Time Source: Auto (GPS)");
if (s.manualTimezoneEnabled) {
char buf[24];
snprintf(buf, sizeof(buf), "UTC Offset: %+d", s.manualUtcOffsetHours);
_list.addItem(buf);
}
_list.addItem("< Back");
}
// Auto-lock preset list — fixed minute values selectable via the
// POPUP_AUTOLOCK popup (see onPopupConfirmed()). 0 = disabled. Kept as a
// single table so the status label (buildSecurityMenu) and the popup's
// options can never drift out of sync with each other.
struct AutoLockOption { uint16_t minutes; const char* label; };
static const AutoLockOption kAutoLockOptions[] = {
{30, "30 min"},
{60, "1 hour"},
{240, "4 hours"},
{480, "8 hours"},
{720, "12 hours"},
{0, "Disabled"},
};
static constexpr int kAutoLockOptionCount = sizeof(kAutoLockOptions) / sizeof(kAutoLockOptions[0]);
static const char* autoLockLabel(uint16_t minutes) {
for (auto& o : kAutoLockOptions) {
if (o.minutes == minutes) return o.label;
}
return "Disabled";
}
// Security menu: each row is a status summary. Both Duress Password and
// Auto-Lock open a small OptionPopup instead of a dedicated page — neither
// has enough options to need one (see openPopup/onPopupConfirmed).
void SettingsScreen::buildSecurityMenu() {
_list.clear();
bool configured = Duress::isConfigured();
_list.addItem(configured ? "Duress Password: Enabled"
: "Duress Password: Disabled");
if (_config) {
std::string label = "Auto-Lock: " + std::string(autoLockLabel(_config->settings().autoLockMinutes));
_list.addItem(label);
}
_list.addItem("< Back");
}
void SettingsScreen::openPopup(PopupPurpose purpose, const std::string& title,
std::vector<std::string> options, int selectedIndex) {
_popupPurpose = purpose;
_optionPopup.open(title, std::move(options), selectedIndex);
}
// Dispatches on the purpose set by openPopup() — one shared confirm path
// for every small fixed-choice setting instead of one bespoke handler per
// popup. Each case does exactly what its old inline toggle/page-select code
// did: write the field, applyAndSave(), rebuild the menu it was opened from.
void SettingsScreen::onPopupConfirmed(int result) {
PopupPurpose purpose = _popupPurpose;
_popupPurpose = POPUP_NONE;
if (!_config) return;
auto& s = _config->settings();
switch (purpose) {
case POPUP_WIFI_MODE:
s.wifiMode = (RatWiFiMode)result;
applyAndSave();
buildWiFiMenu();
openPopup(POPUP_REBOOT_CONFIRM, "Reboot to apply?", {"Reboot now", "Later"}, 0);
break;
case POPUP_DURESS_ACTION: {
bool configured = Duress::isConfigured();
if (result == 0) {
if (configured) {
_confirmPending = true;
_confirmAction = 2;
} else {
startDuressSetup();
}
} else if (result == 1 && configured) {
startDuressSetup();
}
break;
}
case POPUP_AUDIO:
s.audioEnabled = (result == 0);
if (_audio) _audio->setEnabled(s.audioEnabled);
applyAndSave();
buildAudioMenu();
break;
case POPUP_TIME_SOURCE:
s.manualTimezoneEnabled = (result == 1);
applyAndSave();
buildTimeMenu();
break;
case POPUP_AUTO_LAN:
// IPv6 enable is one-shot per WiFi init, so changes take effect
// on next reboot — same as before this was a popup.
s.autoIfaceEnabled = (result == 0);
applyAndSave();
buildWiFiMenu();
openPopup(POPUP_REBOOT_CONFIRM, "Reboot to apply?", {"Reboot now", "Later"}, 0);
break;
case POPUP_THEME_INPUT:
_useMixer = (result == 1);
buildThemeCustomMenu();
break;
case POPUP_AUTOLOCK:
if (result >= 0 && result < kAutoLockOptionCount) {
s.autoLockMinutes = kAutoLockOptions[result].minutes;
applyAndSave();
buildSecurityMenu();
}
break;
case POPUP_REBOOT_CONFIRM:
if (result == 0) ESP.restart();
break;
default:
break;
}
}
// Opens the small inline duress-password popup (see render()'s
// _duressEntryActive block and the handleKey block guarding it) — drawn on
// top of this screen rather than swapping to a different one, and
// cancelable with Esc at any point, same as any other Settings field edit.
// Closes the action popup first if that's what led here (Enable/Reset).
void SettingsScreen::startDuressSetup() {
_duressStage = 0;
_duressFirstPw = "";
_duressInput.clear();
_duressInput.setMaxLength(PasswordScreen::MAX_LEN);
_duressInput.setMasked(true);
_duressInput.setActive(true);
_duressInput.setSubmitCallback([this](const std::string& v) { onDuressInputSubmit(v); });
_duressEntryActive = true;
}
void SettingsScreen::cancelDuressSetup() {
_duressEntryActive = false;
_duressInput.setActive(false);
_duressInput.clear();
IdentityCrypto::secureZero((void*)_duressFirstPw.c_str(), _duressFirstPw.length());
_duressFirstPw = "";
showToast("Cancelled");
}
// Stage 0: validate length + difference from the real at-rest password,
// then advance to confirm. Stage 1: must match stage 0's entry, then
// persist via Duress::setup(). Mirrors the validation PasswordScreen does
// for the boot-time setup flow, just driven locally instead of through a
// pushed screen.
void SettingsScreen::onDuressInputSubmit(const std::string& value) {
String pw = value.c_str();
if (_duressStage == 0) {
if ((int)pw.length() < PasswordScreen::MIN_LEN) {
showToast("Min 6 characters");
_duressInput.clear();
return;
}
if (_rns && _rns->isCurrentPassword(pw)) {
showToast("Must differ from main password");
_duressInput.clear();
return;
}
_duressFirstPw = pw;
_duressStage = 1;
_duressInput.clear();
return;
}
// Stage 1: confirm.
if (pw != _duressFirstPw) {
showToast("Passwords do not match");
IdentityCrypto::secureZero((void*)_duressFirstPw.c_str(), _duressFirstPw.length());
_duressFirstPw = "";
_duressStage = 0;
_duressInput.clear();
return;
}
bool ok = Duress::setup(pw);
IdentityCrypto::secureZero((void*)pw.c_str(), pw.length());
IdentityCrypto::secureZero((void*)_duressFirstPw.c_str(), _duressFirstPw.length());
_duressFirstPw = "";
_duressEntryActive = false;
_duressInput.setActive(false);
_duressInput.clear();
buildSecurityMenu();
showToast(ok ? "Duress password set" : "Failed to set");
}
void SettingsScreen::buildRadioMenu() {
_list.clear();
if (!_config) return;
_list.addItem("Config");
_list.addItem("Diagnostics");
_list.addItem("< Back");
}
void SettingsScreen::buildRadioConfigMenu() {
_list.clear();
if (!_config) return;
auto& s = _config->settings();
char buf[40];
snprintf(buf, sizeof(buf), "Frequency: %lu Hz", (unsigned long)s.loraFrequency);
_list.addItem(buf);
snprintf(buf, sizeof(buf), "Spreading Factor: %d", s.loraSF);
_list.addItem(buf);
snprintf(buf, sizeof(buf), "Bandwidth: %lu Hz", (unsigned long)s.loraBW);
_list.addItem(buf);
snprintf(buf, sizeof(buf), "Coding Rate: %d", s.loraCR);
_list.addItem(buf);
snprintf(buf, sizeof(buf), "TX Power: %d dBm", s.loraTxPower);
_list.addItem(buf);
_list.addItem("< Back");
}
void SettingsScreen::buildWiFiMenu() {
_list.clear();
if (!_config) return;
auto& s = _config->settings();
// Item 0: Mode selector
const char* modeNames[] = {"OFF", "AP", "STA"};
char modeBuf[24];
snprintf(modeBuf, sizeof(modeBuf), "Mode: %s", modeNames[s.wifiMode]);
_list.addItem(modeBuf);
if (s.wifiMode == RAT_WIFI_AP) {
// Items 1-2: AP fields
String apSSID = s.wifiAPSSID.isEmpty() ? "(auto)" : s.wifiAPSSID;
_list.addItem(("AP SSID: " + std::string(apSSID.c_str())));
_list.addItem(("AP Pass: " + std::string(s.wifiAPPassword.c_str())));
} else if (s.wifiMode == RAT_WIFI_STA) {
// Item 1: Connection status
if (WiFi.status() == WL_CONNECTED) {
char statusBuf[48];
snprintf(statusBuf, sizeof(statusBuf), "Connected: %s", WiFi.SSID().c_str());
_list.addItem(statusBuf);
_list.addItem("[Disconnect]"); // Item 2
} else if (!s.wifiSTASSID.isEmpty()) {
char statusBuf[48];
snprintf(statusBuf, sizeof(statusBuf), "Saved: %s (offline)", s.wifiSTASSID.c_str());
_list.addItem(statusBuf);
_list.addItem("[Connect]"); // Item 2
} else {
_list.addItem("No network configured");
_list.addItem(""); // Item 2 placeholder
}
_list.addItem("Scan Networks"); // Item 3
_list.addItem("TCP Connections"); // Item 4
// Item 5: AutoInterface (LAN auto-discovery via IPv6 multicast)
char autoBuf[40];
snprintf(autoBuf, sizeof(autoBuf), "Auto-discover LAN: %s",
s.autoIfaceEnabled ? "ON" : "OFF");
_list.addItem(autoBuf);
}
// OFF mode: only mode selector + back
_list.addItem("< Back");
}
void SettingsScreen::buildTCPMenu() {
_list.clear();
if (!_config) return;
auto& s = _config->settings();
_list.addItem("Add Connection");
for (size_t i = 0; i < s.tcpConnections.size(); i++) {
auto& ep = s.tcpConnections[i];
char buf[64];
snprintf(buf, sizeof(buf), "%s:%d [%s]",
ep.host.c_str(), ep.port,
ep.autoConnect ? "ON" : "OFF");
_list.addItem(buf);
}
_list.addItem("< Back");
}
void SettingsScreen::addTCPConnection(const std::string& host, uint16_t port) {
if (!_config) return;
auto& s = _config->settings();
if (s.tcpConnections.size() >= MAX_TCP_CONNECTIONS) {
showToast("Max 4 connections");
return;
}
TCPEndpoint ep;
ep.host = host.c_str();
ep.port = port;
if (ep.host.isEmpty()) return;
// Test connection before adding (only if WiFi is connected)
if (WiFi.status() == WL_CONNECTED) {
showToast("Testing connection...");
WiFiClient testClient;
if (!testClient.connect(ep.host.c_str(), ep.port, 3000)) {
testClient.stop();
showToast("Connection failed!", 2500);
Serial.printf("[TCP] Test failed: %s:%d\n", ep.host.c_str(), ep.port);
buildTCPMenu();
return;
}
testClient.stop();
}
s.tcpConnections.push_back(ep);
applyAndSave();
if (_tcpReloadCb) {
_tcpReloadCb();
showToast("Added & connecting");
} else {
showToast("Added! Reboot to connect");
}
buildTCPMenu();
}
void SettingsScreen::toggleTCPConnection(int index) {
if (!_config) return;
auto& s = _config->settings();
if (index < 0 || index >= (int)s.tcpConnections.size()) return;
s.tcpConnections[index].autoConnect = !s.tcpConnections[index].autoConnect;
applyAndSave();
// Apply immediately -- previously this only took effect after a reboot,
// since the live TCPClientInterface set is owned by main.cpp and nothing
// here ever told it to rebuild. Disabling a connection without this left
// its interface registered with Transport (still possibly the "best"
// path for some destinations) until a reboot finally tore it down.
if (_tcpReloadCb) _tcpReloadCb();
buildTCPMenu();
}
void SettingsScreen::removeTCPConnection(int index) {
if (!_config) return;
auto& s = _config->settings();
if (index < 0 || index >= (int)s.tcpConnections.size()) return;
s.tcpConnections.erase(s.tcpConnections.begin() + index);
applyAndSave();
if (_tcpReloadCb) _tcpReloadCb();
showToast("Removed");
buildTCPMenu();
}
// =============================================================================
// SD Card submenu
// =============================================================================
void SettingsScreen::buildSDCardMenu() {
_list.clear();
if (_sdStore && _sdStore->isReady()) {
_list.addItem("Status: INSERTED");
char buf[32];
uint64_t total = _sdStore->totalBytes();
uint64_t used = _sdStore->usedBytes();
uint64_t free = total > used ? total - used : 0;
snprintf(buf, sizeof(buf), "Total: %llu MB", total / (1024 * 1024));
_list.addItem(buf);
snprintf(buf, sizeof(buf), "Used: %llu MB", used / (1024 * 1024));
_list.addItem(buf);
snprintf(buf, sizeof(buf), "Free: %llu MB", free / (1024 * 1024));
_list.addItem(buf);
_list.addItem("Initialize SD Storage");
_list.addItem("Wipe All Data", Theme::ERROR);
} else {
_list.addItem("Status: NOT INSERTED");
}
_list.addItem("< Back");
}
void SettingsScreen::sdCardFormat() {
if (!_sdStore || !_sdStore->isReady()) {
showToast("No SD card");
return;
}
if (_sdStore->formatForStandalone()) {
showToast("SD initialized!");
} else {
showToast("Init failed");
}
buildSDCardMenu();
}
// =============================================================================
// WiFi Scanner
// =============================================================================
void SettingsScreen::startWiFiScan() {
_list.clear();
_list.addItem("Scanning...");
Serial.println("[WIFI] Starting network scan...");
// Disconnect from current network to free the radio for scanning
WiFi.disconnect(false); // disconnect but don't erase credentials
delay(100);
// Ensure WiFi is on in STA mode for scanning
if (WiFi.getMode() == WIFI_OFF) {
WiFi.mode(WIFI_STA);
}
int n = WiFi.scanNetworks(false, false);
_scanResults.clear();
if (n > 0) {
for (int i = 0; i < n; i++) {
WiFiNetwork net;
net.ssid = WiFi.SSID(i);
net.rssi = WiFi.RSSI(i);
net.encType = WiFi.encryptionType(i);
if (!net.ssid.isEmpty()) {
_scanResults.push_back(net);
}
}
// Sort by signal strength (strongest first)
std::sort(_scanResults.begin(), _scanResults.end(),
[](const WiFiNetwork& a, const WiFiNetwork& b) {
return a.rssi > b.rssi;
});
}
WiFi.scanDelete();
Serial.printf("[WIFI] Found %d networks\n", (int)_scanResults.size());
_subMenu = MENU_WIFI_SCAN;
buildScanResultsMenu();
}
void SettingsScreen::buildScanResultsMenu() {
_list.clear();
if (_scanResults.empty()) {
_list.addItem("No networks found");
} else {
for (auto& net : _scanResults) {
char buf[48];
const char* lock = (net.encType == WIFI_AUTH_OPEN) ? "" : "*";
snprintf(buf, sizeof(buf), "%s%s (%d dBm)",
lock, net.ssid.c_str(), net.rssi);
_list.addItem(buf);
}
}
_list.addItem("Rescan");
_list.addItem("< Back");
}
void SettingsScreen::disconnectWiFi() {
WiFi.disconnect(false);
showToast("Disconnected");
buildWiFiMenu();
}
void SettingsScreen::connectWiFi() {
auto& s = _config->settings();
if (s.wifiSTASSID.isEmpty()) {
showToast("No SSID set");
return;
}
WiFi.disconnect(false);
delay(100);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(false);
WiFi.begin(s.wifiSTASSID.c_str(), s.wifiSTAPassword.c_str());
showToast("Connecting...");
buildWiFiMenu();
}
void SettingsScreen::selectNetwork(int index) {
if (index < 0 || index >= (int)_scanResults.size()) return;
if (!_config) return;
auto& s = _config->settings();
s.wifiSTASSID = _scanResults[index].ssid;
Serial.printf("[WIFI] Selected: %s\n", s.wifiSTASSID.c_str());
// Open password editor
_subMenu = MENU_WIFI;
// field 1 = STA password in WiFi STA mode
startEditing(1, s.wifiSTAPassword.c_str());
}
// =============================================================================
// Display / Audio menus
// =============================================================================
void SettingsScreen::buildDisplayMenu() {
_list.clear();
if (!_config) return;
auto& s = _config->settings();
_list.addItem("Screen");
_list.addItem("Theme");
String name = s.displayName.isEmpty() ? "(none)" : s.displayName;
_list.addItem("Device Name: " + std::string(name.c_str()));
_list.addItem("< Back");
}
void SettingsScreen::buildDisplayScreenMenu() {
_list.clear();
if (!_config) return;
auto& s = _config->settings();
char buf[40];
snprintf(buf, sizeof(buf), "Brightness: %d", s.brightness);
_list.addItem(buf);
snprintf(buf, sizeof(buf), "Dim timeout: %ds", s.screenDimTimeout);
_list.addItem(buf);
snprintf(buf, sizeof(buf), "Off timeout: %ds", s.screenOffTimeout);
_list.addItem(buf);
_list.addItem("< Back");
}
void SettingsScreen::buildAudioMenu() {
_list.clear();
if (!_config) return;
auto& s = _config->settings();
char buf[40];
_list.addItem(s.audioEnabled ? "Audio: ON" : "Audio: OFF");
snprintf(buf, sizeof(buf), "Volume: %d%%", s.audioVolume);
_list.addItem(buf);
_list.addItem("< Back");
}
// =============================================================================
// Theme submenu — presets, plus per-hue hex editing for a custom theme.
// Colors live in their own plaintext theme.json (see docs/theme-config.md),
// not in the encrypted UserConfig blob — they aren't sensitive, and the
// boot/password screens need a theme before any identity is unlocked.
// =============================================================================
void SettingsScreen::buildThemeMenu() {
_list.clear();
char buf[40];
Theme::BaseColors c = Theme::current();
snprintf(buf, sizeof(buf), "Active: %s", c.name.c_str());
_list.addItem(buf);
int activeIdx = Theme::activePresetIndex();
for (int i = 0; i < Theme::PRESET_COUNT; i++) {
char label[40];
snprintf(label, sizeof(label), "%s[%s]",
(i == activeIdx) ? ">" : " ", Theme::PRESETS[i].name);
_list.addItem(label, (i == activeIdx) ? Theme::PRIMARY : 0);
}
// "Custom" is a selectable row in the same list as the presets above —
// not a separate menu link — so it reads as one more option to pick,
// not a different kind of action. Selecting it opens the per-hue editor.
char customLabel[40];
snprintf(customLabel, sizeof(customLabel), "%s[Custom]", (activeIdx < 0) ? ">" : " ");
_list.addItem(customLabel, (activeIdx < 0) ? Theme::PRIMARY : 0);
_list.addItem("< Back");
}
void SettingsScreen::applyThemePreset(int index) {
if (index < 0 || index >= Theme::PRESET_COUNT) return;
const Theme::Preset& p = Theme::PRESETS[index];
Theme::BaseColors c;
c.name = p.name;
c.bg = p.bg; c.text = p.text; c.primary = p.primary;
c.secondary = p.secondary; c.accent = p.accent;
c.error = p.error; c.warning = p.warning;
Theme::apply(c);
Theme::save(_flash, _sdStore);
if (_ui) _ui->refreshPalette();
buildThemeMenu();
showToast("Theme applied!");
}
void SettingsScreen::buildThemeCustomMenu() {
_list.clear();
Theme::BaseColors c = Theme::current();
// Optional alternative to typing hex directly — toggled here, applies to
// whichever hue you pick below. Defaults to Hex (fastest); Mix opens an
// RGB slider screen instead.
_list.addItem(_useMixer ? "Input: Mix" : "Input: Hex");
for (const auto& f : kThemeFields) {
_list.addItem(std::string(f.label) + ": " + hexForBaseField(c, f.index));
}
_list.addItem("< Back");
}
// Seed the mixer's working R/G/B from the field's current hex value.
void SettingsScreen::startMixer(int field) {
Theme::BaseColors c = Theme::current();
std::string hex = hexForBaseField(c, field);
uint8_t r, g, b;
if (!parseHex6(hex, r, g, b)) { r = g = b = 0; }
_mixerField = field;
_mixerChannel = 0;
_mixerR = r; _mixerG = g; _mixerB = b;
_mixerHeldKey = 0;
_mixerHeldSince = 0;
_mixerLastStepAt = 0;
_subMenu = MENU_THEME_MIXER;
}
// How far a single nudge moves the selected channel, given how long the key
// has been held. The UI canvas renders at 8bpp truecolor (3 red bits, 3
// green bits, 2 blue bits — not an indexed palette, despite
// setPaletteColor() calls elsewhere suggesting otherwise), so a fixed step
// of 1 can sit inside the same quantization bucket for up to 31 (R/G) or 63
// (B) consecutive presses with no visible color change at all. The step
// only needs to be big enough that a few presses' worth of *accumulated*
// movement crosses a bucket within a fraction of a second — it doesn't
// need to guarantee a new bucket on every single press, which is what made
// the first version of this ramp feel like it was teleporting. Kept
// deliberately gentle: a tap (or the very start of a hold) still moves by
// exactly 1 for precise, ungapped adjustment, and even at full cruise the
// step is a fraction of a bucket width, not a whole one.
static int mixerStepSize(int channel, unsigned long held) {
if (held < 500) return 1;
int unit = (channel == 2) ? 2 : 1; // blue's bucket is 2x wider than red/green's
if (held < 1500) return 2 * unit;
if (held < 3000) return 4 * unit;
return 8 * unit;
}
// Nudge whichever channel is currently selected, by `amount` (signed).
void SettingsScreen::stepMixerChannel(int amount) {
uint8_t* chan = (_mixerChannel == 0) ? &_mixerR : (_mixerChannel == 1) ? &_mixerG : &_mixerB;
int v = (int)*chan + amount;
if (v < 0) v = 0;
if (v > 255) v = 255;
*chan = (uint8_t)v;
if (_ui) _ui->markContentDirty();
}
void SettingsScreen::commitMixer() {
char hexBuf[7];
snprintf(hexBuf, sizeof(hexBuf), "%02X%02X%02X", _mixerR, _mixerG, _mixerB);
Theme::BaseColors c = Theme::current();
c.name = "Custom";
switch (_mixerField) {
case 0: c.bg = hexBuf; break;
case 1: c.text = hexBuf; break;
case 2: c.primary = hexBuf; break;
case 3: c.secondary = hexBuf; break;
case 4: c.accent = hexBuf; break;
case 5: c.error = hexBuf; break;
case 6: c.warning = hexBuf; break;
}
Theme::apply(c);
Theme::save(_flash, _sdStore);
if (_ui) _ui->refreshPalette();
showToast("Saved!");
_subMenu = MENU_THEME_CUSTOM;
buildThemeCustomMenu();
}
// Start editing a field — show TextInput with current value. `label`
// replaces the generic "Edit value:" prompt; always set explicitly (even to
// "") so a label from a previous edit never leaks into the next one.
void SettingsScreen::startEditing(int field, const std::string& currentValue, const std::string& label) {
_editField = field;
_editing = true;
_editLabel = label;
_editInput.clear();
_editInput.setText(currentValue);
_editInput.setActive(true);
_editInput.setMaxLength(_subMenu == MENU_THEME_CUSTOM ? 6 : 64);
_editInput.setSubmitCallback([this](const std::string& value) {
commitEdit(value);
});
}
// Apply edited value to settings
void SettingsScreen::commitEdit(const std::string& value) {
if (!_config) return;
auto& s = _config->settings();
if (_subMenu == MENU_RADIO_CONFIG) {
long v = atol(value.c_str());
switch (_editField) {
case 0: // Frequency: 150MHz-960MHz
if (v >= 150000000L && v <= 960000000L) s.loraFrequency = (uint32_t)v;
break;
case 1: // SF: 5-12
if (v >= 5 && v <= 12) s.loraSF = (uint8_t)v;
break;
case 2: // BW: 7800-500000
if (v >= 7800 && v <= 500000) s.loraBW = (uint32_t)v;
break;
case 3: // CR: 5-8
if (v >= 5 && v <= 8) s.loraCR = (uint8_t)v;
break;
case 4: // TX Power
if (v >= -3 && v <= LORA_MAX_TX_POWER) {
s.loraTxPower = (int8_t)v;
} else {
_editInput.setError(true);
showToast("Only -3 to 20 dBm");
return; // stay in edit mode — rejection must be visible, not silent
}
break;
}
applyAndSave();
buildRadioConfigMenu();
} else if (_subMenu == MENU_TCP) {
if (_editField == 99 && !value.empty()) {
// Host submitted — stash and open port input
_tcpPendingHost = value;
_editField = 100;
_editing = true;
_editLabel = "Port (1-9999):";
_editInput.clear();
_editInput.setText("4242");
_editInput.setActive(true);
_editInput.setMaxLength(5);
_editInput.setNumericOnly(true);
_editInput.setSubmitCallback([this](const std::string& v) {
commitEdit(v);
});
return; // Stay in editing mode
}
if (_editField == 100 && !value.empty()) {
// Port submitted — validate and add
int port = atoi(value.c_str());
if (port < 1 || port > 9999) {
showToast("Port 1-9999");
buildTCPMenu();
} else {
addTCPConnection(_tcpPendingHost, (uint16_t)port);
}
_tcpPendingHost.clear();
}
buildTCPMenu();
} else if (_subMenu == MENU_WIFI) {
// Fields: 0=SSID, 1=Password (AP or STA depending on mode)
if (_config->settings().wifiMode == RAT_WIFI_AP) {
switch (_editField) {
case 0: s.wifiAPSSID = value.c_str(); break;
case 1: s.wifiAPPassword = value.c_str(); break;
}
} else if (_config->settings().wifiMode == RAT_WIFI_STA) {
switch (_editField) {
case 0: s.wifiSTASSID = value.c_str(); break;
case 1: s.wifiSTAPassword = value.c_str(); break;
}
}
applyAndSave();
if (_config->settings().wifiMode == RAT_WIFI_STA && _editField == 1) {
connectWiFi(); // Live reconnect with new credentials
} else {
showToast("Saved!");
}
buildWiFiMenu();
} else if (_subMenu == MENU_DISPLAY) {
if (_editField == 0) s.displayName = value.c_str();
applyAndSave();
buildDisplayMenu();
} else if (_subMenu == MENU_DISPLAY_SCREEN) {
switch (_editField) {
case 0: {
int v = atoi(value.c_str());
if (v < 1) v = 1;
if (v > 255) v = 255;
s.brightness = (uint8_t)v;
if (_power) _power->setBrightness(s.brightness);
break;
}
case 1: s.screenDimTimeout = (uint16_t)atoi(value.c_str()); break;
case 2: s.screenOffTimeout = (uint16_t)atoi(value.c_str()); break;
}
applyAndSave();
buildDisplayScreenMenu();
} else if (_subMenu == MENU_AUDIO) {
if (_editField == 1) {
int v = atoi(value.c_str());
if (v < 0) v = 0;
if (v > 100) v = 100;
s.audioVolume = (uint8_t)v;
if (_audio) _audio->setVolume(s.audioVolume);
}
applyAndSave();
buildAudioMenu();
} else if (_subMenu == MENU_MESSAGING) {
if (_editField == 0) {
int v = constrain(atoi(value.c_str()), 1, 24);
s.stampCostCeiling = (uint8_t)v;
}
applyAndSave();
buildMessagingMenu();
} else if (_subMenu == MENU_TIME) {
if (_editField == 1) {
int v = atoi(value.c_str());
if (v >= -12 && v <= 14) s.manualUtcOffsetHours = (int8_t)v;
}
applyAndSave();
buildTimeMenu();
} else if (_subMenu == MENU_THEME_CUSTOM) {
// Exactly 6 hex digits or reject outright (previous color kept) —
// no silent coercion of a malformed/truncated value.
bool valid = value.size() == 6;
for (size_t i = 0; valid && i < value.size(); i++) {
valid = isxdigit((unsigned char)value[i]);
}
if (!valid) {
showToast("Use 6 hex digits (RRGGBB)");
_editing = false;
_editField = -1;
return;
}
std::string hex = value;
for (auto& ch : hex) ch = (char)toupper((unsigned char)ch);
Theme::BaseColors c = Theme::current();
c.name = "Custom";
switch (_editField) {
case 0: c.bg = hex.c_str(); break;
case 1: c.text = hex.c_str(); break;
case 2: c.primary = hex.c_str(); break;
case 3: c.secondary = hex.c_str(); break;
case 4: c.accent = hex.c_str(); break;
case 5: c.error = hex.c_str(); break;
case 6: c.warning = hex.c_str(); break;
}
Theme::apply(c);
Theme::save(_flash, _sdStore);
if (_ui) _ui->refreshPalette();
showToast("Saved!");
buildThemeCustomMenu();
}
_editing = false;
_editField = -1;
}
// Get current value of a field as string for editing
std::string SettingsScreen::getCurrentValue(SubMenu menu, int field) {
if (!_config) return "";
auto& s = _config->settings();
char buf[32];
if (menu == MENU_RADIO_CONFIG) {
switch (field) {
case 0: snprintf(buf, sizeof(buf), "%lu", (unsigned long)s.loraFrequency); return buf;
case 1: snprintf(buf, sizeof(buf), "%d", s.loraSF); return buf;
case 2: snprintf(buf, sizeof(buf), "%lu", (unsigned long)s.loraBW); return buf;
case 3: snprintf(buf, sizeof(buf), "%d", s.loraCR); return buf;
case 4: snprintf(buf, sizeof(buf), "%d", s.loraTxPower); return buf;
}
} else if (menu == MENU_WIFI) {
if (s.wifiMode == RAT_WIFI_AP) {
switch (field) {
case 0: return s.wifiAPSSID.c_str();
case 1: return s.wifiAPPassword.c_str();
}
} else if (s.wifiMode == RAT_WIFI_STA) {
switch (field) {
case 0: return s.wifiSTASSID.c_str();
case 1: return s.wifiSTAPassword.c_str();
}
}
} else if (menu == MENU_DISPLAY) {
if (field == 0) return s.displayName.c_str();
} else if (menu == MENU_DISPLAY_SCREEN) {
switch (field) {
case 0: snprintf(buf, sizeof(buf), "%d", s.brightness); return buf;
case 1: snprintf(buf, sizeof(buf), "%d", s.screenDimTimeout); return buf;
case 2: snprintf(buf, sizeof(buf), "%d", s.screenOffTimeout); return buf;
}
} else if (menu == MENU_AUDIO) {
if (field == 1) { snprintf(buf, sizeof(buf), "%d", s.audioVolume); return buf; }
} else if (menu == MENU_MESSAGING) {