-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathuiPages.cpp
More file actions
2056 lines (1859 loc) · 68.6 KB
/
Copy pathuiPages.cpp
File metadata and controls
2056 lines (1859 loc) · 68.6 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 "uiPages.h"
#include <SPIFFS.h> // filesystem usage row on the status page
#include <WiFi.h>
#include <esp_system.h> // esp_reset_reason - reset reason row
#include <esp_timer.h> // 64-bit uptime for formatUptime (no 49.7-day wrap)
#include <soc/soc_caps.h> // SOC_TEMP_SENSOR_SUPPORTED - CPU temp row
#include "brightness.h"
#include "clockFaces.h" // ClockFace enum, clockFaceName
#include "deviceIdentity.h" // startup device label + MAC
#include "drdGuard.h" // rebootCleanly - reboot button / auto-reboot
#include "firmwareInfo.h" // firmwareGitHash
#include "genericBaseProject.h" // NTP sync state - status page rows
#include "holidayService.h" // holidaysInvalidate, holidayZonesLoaded
#include "marketHolidays.h" // marketHolidaysFetchedInfo - status page
#include "projectConfig.h"
#include "uiScale.h" // scaleUiX/Y - shared 320x240 design scaling
#include "weatherService.h" // weatherInvalidate
#include "wifiWatch.h" // outage history rows on the status page
#include "netCheck.h" // captivePortalActive - Wi-Fi login helper
#include "wifiRelay.h" // captive-portal login relay
UIScreen uiScreen = SCREEN_HOME;
bool uiPageDrawn = false; // false -> render the full page on the next loop
int zoneSlotBeingEdited = 0; // which quadrant the timezone list is editing
int tzListPage = 0; // current page of the timezone list
unsigned long lastStatusRefresh = 0;
// After a screen switch, ignore the touch panel until the finger is lifted so
// a single tap can't "click through" onto the newly drawn page.
bool touchSuppressedUntilRelease = false;
/*-------- Timezone presets ----------*/
// The posix column carries each zone's current POSIX TZ rules (matching the
// tz database's POSIX representation, including DST transitions). They are
// only used as a fallback when the timezone server is unreachable AND no
// cached definition exists, so the clock still shows correct local time on a
// first boot without the server. Update an entry if a region changes its
// DST law (rare).
//
// The country column feeds the public-holiday service (date.nager.at). It is
// "" for Dubai and Mumbai because that API has no AE / IN calendars - those
// zones simply show no holidays.
const TimezonePreset TZ_PRESETS[] = {
{"SANTA CLARA", "America/Los_Angeles", 37.35, -121.95, "PST8PDT,M3.2.0,M11.1.0", "US"},
{"DENVER", "America/Denver", 39.74, -104.99, "MST7MDT,M3.2.0,M11.1.0", "US"},
{"CHICAGO", "America/Chicago", 41.88, -87.63, "CST6CDT,M3.2.0,M11.1.0", "US"},
{"NEW YORK", "America/New_York", 40.71, -74.01, "EST5EDT,M3.2.0,M11.1.0", "US"},
{"SAO PAULO", "America/Sao_Paulo", -23.55, -46.63, "<-03>3", "BR"},
{"LONDON", "Europe/London", 51.51, -0.13, "GMT0BST,M3.5.0/1,M10.5.0", "GB"},
{"PARIS", "Europe/Paris", 48.86, 2.35, "CET-1CEST,M3.5.0,M10.5.0/3", "FR"},
{"BERLIN", "Europe/Berlin", 52.52, 13.41, "CET-1CEST,M3.5.0,M10.5.0/3", "DE"},
{"MOSCOW", "Europe/Moscow", 55.76, 37.62, "MSK-3", "RU"},
{"DUBAI", "Asia/Dubai", 25.20, 55.27, "<+04>-4", ""},
{"MUMBAI", "Asia/Kolkata", 19.08, 72.88, "IST-5:30", ""},
{"SINGAPORE", "Asia/Singapore", 1.35, 103.82, "<+08>-8", "SG"},
{"HONG KONG", "Asia/Hong_Kong", 22.32, 114.17, "HKT-8", "HK"},
{"BEIJING", "Asia/Shanghai", 39.90, 116.41, "CST-8", "CN"},
{"TOKYO", "Asia/Tokyo", 35.68, 139.69, "JST-9", "JP"},
{"SEOUL", "Asia/Seoul", 37.57, 126.98, "KST-9", "KR"},
{"SYDNEY", "Australia/Sydney", -33.87, 151.21, "AEST-10AEDT,M10.1.0,M4.1.0/3", "AU"},
{"AUCKLAND", "Pacific/Auckland", -36.85, 174.76, "NZST-12NZDT,M9.5.0,M4.1.0/3", "NZ"},
};
const int TZ_PRESET_COUNT = sizeof(TZ_PRESETS) / sizeof(TZ_PRESETS[0]);
const int TZ_PER_PAGE = 5;
const char *getPosixFallback(const String &tz)
{
for (int i = 0; i < TZ_PRESET_COUNT; i++)
{
if (tz == TZ_PRESETS[i].tz)
{
return TZ_PRESETS[i].posix;
}
}
return nullptr;
}
const char *getCountryForTimezone(const String &tz)
{
for (int i = 0; i < TZ_PRESET_COUNT; i++)
{
if (tz == TZ_PRESETS[i].tz)
{
return TZ_PRESETS[i].country;
}
}
return nullptr;
}
bool getCityCoords(const String &tz, float &lat, float &lon)
{
for (int i = 0; i < TZ_PRESET_COUNT; i++)
{
if (tz == TZ_PRESETS[i].tz)
{
lat = TZ_PRESETS[i].lat;
lon = TZ_PRESETS[i].lon;
return true;
}
}
return false;
}
MarketInfo getMarketInfoForTimezone(const String &tz)
{
if (tz == "America/New_York")
{
return {"NYSE", true, {
{9, 30, 16, 0, "REGULAR"},
{16, 0, 20, 0, "AFTER-HRS"},
{20, 0, 4, 0, "OVERNIGHT"},
{4, 0, 9, 30, "PRE-MARKET"}
}, 4};
}
if (tz == "Asia/Shanghai")
{
return {"SSE", true, {
{9, 0, 9, 30, "PRE-MARKET"},
{9, 30, 11, 30, "REGULAR"},
{13, 0, 15, 0, "REGULAR"},
{15, 0, 15, 30, "AFTER-HRS"}
}, 4};
}
if (tz == "Europe/London")
{
return {"LSE", true, {
{7, 15, 8, 0, "PRE-MARKET"},
{8, 0, 16, 30, "REGULAR"},
{16, 30, 17, 0, "CLOSING"},
{17, 0, 17, 30, "AFTER-HRS"}
}, 4};
}
if (tz == "Asia/Tokyo")
{
return {"TSE", true, {
{9, 0, 11, 30, "REGULAR"},
{12, 30, 15, 30, "REGULAR"}
}, 2};
}
if (tz == "Asia/Hong_Kong")
{
return {"HKEX", true, {
{9, 30, 12, 0, "REGULAR"},
{13, 0, 16, 0, "REGULAR"}
}, 2};
}
return {"", false, {}, 0};
}
void applyConfiguredZones()
{
for (int i = 0; i < 4; i++)
{
if (projectConfig.zoneTZ[i].length() == 0)
continue;
worldZones[i].name = projectConfig.zoneName[i];
worldZones[i].timezone = projectConfig.zoneTZ[i];
worldZones[i].market = getMarketInfoForTimezone(projectConfig.zoneTZ[i]);
}
}
static bool zoneRulesMatch(WorldClockZone &zone, const String &timezone)
{
return zone.tz.getOlson().equalsIgnoreCase(timezone);
}
/*-------- Buttons ----------*/
struct UIButton
{
int x, y, w, h;
};
bool buttonContains(const UIButton &b, int tx, int ty)
{
return tx >= b.x && tx < b.x + b.w && ty >= b.y && ty < b.y + b.h;
}
static UIButton scaleUiButton(const UIButton &base)
{
UIButton b = {
scaleUiX(base.x),
scaleUiY(base.y),
scaleUiX(base.w),
scaleUiY(base.h)
};
if (b.x + b.w > screenWidth)
b.w = screenWidth - b.x;
if (b.y + b.h > screenHeight)
b.h = screenHeight - b.y;
return b;
}
static int uiTextColsFromX(int x)
{
int px = screenWidth - x - scaleUiX(4);
return max(1, px / 6);
}
void drawButton(const UIButton &b, const String &label, uint16_t border, uint16_t textColor)
{
tft.drawRoundRect(b.x, b.y, b.w, b.h, 6, border);
tft.setTextFont(2);
tft.setTextSize(1);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(textColor, clockBackgroundColor);
tft.drawString(label, b.x + b.w / 2, b.y + b.h / 2);
}
// Settings page layout (7 rows, 30px pitch, below the 26px title)
const UIButton BTN_SET_TZ = {20, 28, 280, 26};
const UIButton BTN_SET_FACE = {20, 58, 280, 26};
const UIButton BTN_SET_CLK = {20, 88, 280, 26};
const UIButton BTN_SET_DATE = {20, 118, 280, 26};
// Row 148 holds two toggles side by side: quadrant grid + weather alerts.
const UIButton BTN_SET_GRID = {20, 148, 135, 26};
const UIButton BTN_SET_WXALERT = {165, 148, 135, 26};
const UIButton BTN_SET_DIM = {20, 178, 60, 26};
const UIButton BTN_SET_BRI = {240, 178, 60, 26};
// Bottom row: status / logs / touch calibration / WiFi helper / back.
const UIButton BTN_SET_STAT = {8, 208, 54, 26};
const UIButton BTN_SET_LOGS = {66, 208, 42, 26};
const UIButton BTN_SET_TOUCH = {112, 208, 50, 26};
const UIButton BTN_SET_WIFI = {166, 208, 82, 26};
const UIButton BTN_SET_BACK = {252, 208, 60, 26};
const UIButton SETTINGS_BRIGHTNESS_LABEL = {85, 178, 150, 26};
static UIButton settingsButton(const UIButton &base)
{
return scaleUiButton(base);
}
// Wi-Fi login helper page: a single "Done" button (mirrors BTN_SET_BACK).
const UIButton BTN_WIFI_DONE = {90, 202, 140, 32};
// Wi-Fi failure page: Reboot / Settings side by side.
const UIButton BTN_FAIL_REBOOT = {30, 192, 120, 34};
const UIButton BTN_FAIL_SET = {170, 192, 120, 34};
// Zone-pick page layout (2x2 grid mirroring the clock quadrants)
const UIButton BTN_ZONE[4] = {
{10, 36, 145, 76},
{165, 36, 145, 76},
{10, 118, 145, 76},
{165, 118, 145, 76}
};
const UIButton BTN_ZONE_BACK = {90, 202, 140, 32};
const char *SLOT_LABELS[4] = {"TOP-LEFT", "TOP-RIGHT", "BOTTOM-LEFT", "BOTTOM-RIGHT"};
// Timezone list page layout
UIButton tzRowButton(int row)
{
UIButton b = {10, 34 + row * 32, 300, 28};
return scaleUiButton(b);
}
const UIButton BTN_TZ_PREV = {10, 202, 90, 32};
const UIButton BTN_TZ_BACK = {115, 202, 90, 32};
const UIButton BTN_TZ_NEXT = {220, 202, 90, 32};
/*-------- Touch input ----------*/
// Edge-triggered touch: fires once per physical tap (press after a release).
bool uiNewTouch(int &tx, int &ty)
{
static bool wasDown = false;
static unsigned long lastFire = 0;
TouchPoint t = readTouchPoint();
bool down = (t.zRaw > 800);
bool fired = false;
if (!down)
{
touchSuppressedUntilRelease = false;
}
else if (!wasDown && !touchSuppressedUntilRelease && millis() - lastFire > 150)
{
tx = t.x;
ty = t.y;
fired = true;
lastFire = millis();
}
wasDown = down;
return fired;
}
/*-------- Screen switching ----------*/
void switchToScreen(UIScreen s)
{
// Leaving the Wi-Fi login helper: always tear the helper AP + NAT back down
// so the clock returns to normal STA operation, whatever the exit path.
if (uiScreen == SCREEN_WIFI_LOGIN && s != SCREEN_WIFI_LOGIN)
{
wifiRelayStop();
}
// Leaving the touch calibration screen: restore the user's orientation
// (the screen forces the board's normal rotation while sampling corners),
// whatever the exit path.
if (uiScreen == SCREEN_TOUCH_CAL && s != SCREEN_TOUCH_CAL)
{
tft.setRotation(projectConfig.flipDisplay ? BOARD_TFT_ROTATION_FLIPPED
: BOARD_TFT_ROTATION_NORMAL);
// Repaint whichever page is next in the restored orientation
tft.fillScreen(clockBackgroundColor);
}
uiScreen = s;
uiPageDrawn = false;
touchSuppressedUntilRelease = true; // don't click through onto the new page
if (s == SCREEN_HOME)
{
// Force a full clock redraw when returning home
invalidateHomeScreen(true);
}
}
void openWifiLoginHelper()
{
wifiRelayStart(); // bring up the helper AP + NAT before showing the screen
switchToScreen(SCREEN_WIFI_LOGIN);
}
/*-------- Touch calibration screen ----------*/
// Both supported boards use an XPT2046 but through different drivers. This
// non-blocking wizard samples raw readings on either path, keeping the web
// server responsive while the user taps the four corner arrows.
// Distinguishes CYD xMin/xMax/yMin/yMax data from TFT_eSPI's fifth flags word.
static const uint16_t TOUCH_CAL_BITBANG_MARKER = 0x8001;
bool touchCalibrationAvailable()
{
if (!projectConfig.touchCalSet) return false;
#if BOARD_TOUCH_DRIVER == BOARD_TOUCH_DRIVER_BITBANG
return projectConfig.touchCal[4] == TOUCH_CAL_BITBANG_MARKER;
#else
return projectConfig.touchCal[4] <= 7; // rotate/invert flags only
#endif
}
static const int TCAL_ARROW = 24; // corner arrow size
static const int TCAL_SAMPLES = 8; // raw samples per corner
static const uint16_t TCAL_Z_MIN = 200; // Z_THRESHOLD/2, corners read weak
static const uint16_t TCAL_RAW_ERR = 20; // deadband between paired reads
static const unsigned long TCAL_TIMEOUT_MS = 60000; // per-corner give-up
// Corner order matches TFT_eSPI::calibrateTouch so its mapping math can be
// reused verbatim: 0 = top-left, 1 = bottom-left, 2 = top-right,
// 3 = bottom-right. touchCalRaw holds the averaged raw x,y per corner.
static int touchCalCorner;
static int touchCalSampleCount;
static int32_t touchCalRaw[8];
static bool touchCalWaitRelease;
static unsigned long touchCalCornerStart;
static void drawTouchCalArrow(int corner, uint16_t color)
{
int s = TCAL_ARROW;
int w = screenWidth, h = screenHeight;
switch (corner)
{
case 0: // top-left
tft.drawLine(0, 0, 0, s, color);
tft.drawLine(0, 0, s, 0, color);
tft.drawLine(0, 0, s, s, color);
break;
case 1: // bottom-left
tft.drawLine(0, h - s - 1, 0, h - 1, color);
tft.drawLine(0, h - 1, s, h - 1, color);
tft.drawLine(0, h - 1, s, h - s - 1, color);
break;
case 2: // top-right
tft.drawLine(w - s - 1, 0, w - 1, 0, color);
tft.drawLine(w - 1, 0, w - 1, s, color);
tft.drawLine(w - 1, 0, w - s - 1, s, color);
break;
case 3: // bottom-right
tft.drawLine(w - s - 1, h - 1, w - 1, h - 1, color);
tft.drawLine(w - 1, h - 1 - s, w - 1, h - 1, color);
tft.drawLine(w - 1, h - 1, w - s - 1, h - s - 1, color);
break;
}
}
static void drawTouchCalProgress()
{
tft.setTextFont(2);
tft.setTextSize(1);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_YELLOW, clockBackgroundColor);
tft.drawString("Corner " + String(touchCalCorner + 1) + " of 4 ",
screenWidth / 2, screenHeight / 2 + scaleUiY(30));
}
static void renderTouchCalPage()
{
tft.fillScreen(clockBackgroundColor);
tft.setTextFont(4);
tft.setTextSize(1);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_WHITE, clockBackgroundColor);
tft.drawString("TOUCH CALIBRATION", screenWidth / 2,
screenHeight / 2 - scaleUiY(24));
tft.setTextFont(2);
tft.setTextColor(TFT_LIGHTGREY, clockBackgroundColor);
tft.drawString("Tap the tip of each green arrow",
screenWidth / 2, screenHeight / 2);
tft.drawString("Untouched, the clock returns by itself",
screenWidth / 2, screenHeight / 2 + scaleUiY(14));
drawTouchCalProgress();
drawTouchCalArrow(touchCalCorner, TFT_GREEN);
}
static void closeTouchCalibration()
{
// switchToScreen restores the user's orientation when leaving this screen
switchToScreen(SCREEN_HOME);
}
// Port of the tail of TFT_eSPI::calibrateTouch: derive the axis swap, the
// per-axis inversion and the raw ranges from the four corner readings.
static void finishTouchCalibration()
{
const int32_t *v = touchCalRaw;
#if BOARD_TOUCH_DRIVER == BOARD_TOUCH_DRIVER_TFT_ESPI
int32_t x0, x1, y0, y1;
bool rotate = abs(v[0] - v[2]) > abs(v[1] - v[3]);
if (rotate)
{
x0 = (v[1] + v[3]) / 2; // raw y tracks screen x
x1 = (v[5] + v[7]) / 2;
y0 = (v[0] + v[4]) / 2;
y1 = (v[2] + v[6]) / 2;
}
else
{
x0 = (v[0] + v[2]) / 2;
x1 = (v[4] + v[6]) / 2;
y0 = (v[1] + v[5]) / 2;
y1 = (v[3] + v[7]) / 2;
}
// Same sanity check as the bitbang branch below: four taps in roughly the
// same spot produce a near-zero raw span, and persisting that mapping
// makes touch unusable until recalibrated via serial/web.
if (abs(x0 - x1) < 100 || abs(y0 - y1) < 100)
{
Log.println("Touch calibration rejected - corner readings are too close");
closeTouchCalibration();
return;
}
bool invertX = x0 > x1;
if (invertX)
{
int32_t t = x0; x0 = x1; x1 = t;
}
bool invertY = y0 > y1;
if (invertY)
{
int32_t t = y0; y0 = y1; y1 = t;
}
x1 -= x0;
y1 -= y0;
if (x0 == 0) x0 = 1;
if (x1 == 0) x1 = 1;
if (y0 == 0) y0 = 1;
if (y1 == 0) y1 = 1;
projectConfig.touchCal[0] = (uint16_t)x0;
projectConfig.touchCal[1] = (uint16_t)x1;
projectConfig.touchCal[2] = (uint16_t)y0;
projectConfig.touchCal[3] = (uint16_t)y1;
projectConfig.touchCal[4] = (uint16_t)(rotate | (invertX << 1) | (invertY << 2));
#else
// The bitbang driver maps the raw axes directly. Preserve reversed ranges
// (Arduino map supports them) so panel variants with an inverted axis do
// not need separate orientation flags.
int32_t left = (v[0] + v[2]) / 2;
int32_t right = (v[4] + v[6]) / 2;
int32_t top = (v[1] + v[5]) / 2;
int32_t bottom = (v[3] + v[7]) / 2;
if (abs(left - right) < 100 || abs(top - bottom) < 100)
{
Log.println("Touch calibration rejected - corner readings are too close");
closeTouchCalibration();
return;
}
projectConfig.touchCal[0] = (uint16_t)left;
projectConfig.touchCal[1] = (uint16_t)right;
projectConfig.touchCal[2] = (uint16_t)top;
projectConfig.touchCal[3] = (uint16_t)bottom;
projectConfig.touchCal[4] = TOUCH_CAL_BITBANG_MARKER;
#endif
projectConfig.touchCalSet = true;
projectConfig.saveConfigFile();
#if BOARD_TOUCH_DRIVER == BOARD_TOUCH_DRIVER_TFT_ESPI
tft.setTouch(projectConfig.touchCal);
#else
touchscreen.setCalibration(projectConfig.touchCal[0], projectConfig.touchCal[1],
projectConfig.touchCal[2], projectConfig.touchCal[3]);
#endif
Log.print("Touch calibration saved: ");
for (int i = 0; i < 5; i++)
{
Log.print(projectConfig.touchCal[i]);
Log.print(i < 4 ? ", " : "\n");
}
closeTouchCalibration();
}
// Called every loop while SCREEN_TOUCH_CAL is showing (renderUiPage).
static void serviceTouchCalScreen()
{
if (millis() - touchCalCornerStart > TCAL_TIMEOUT_MS)
{
Log.println("Touch calibration timed out - keeping the previous mapping");
closeTouchCalibration();
return;
}
uint16_t xa, ya, xb, yb;
#if BOARD_TOUCH_DRIVER == BOARD_TOUCH_DRIVER_TFT_ESPI
uint16_t za = tft.getTouchRawZ();
#else
TouchPoint first = touchscreen.getTouch();
uint16_t za = first.zRaw;
#endif
if (za < TCAL_Z_MIN)
{
touchCalWaitRelease = false;
return;
}
if (touchCalWaitRelease)
return;
// Two reads a moment apart must agree (the library's validTouch
// deadband) so a finger sliding onto the corner doesn't skew the average.
#if BOARD_TOUCH_DRIVER == BOARD_TOUCH_DRIVER_TFT_ESPI
tft.getTouchRaw(&xa, &ya);
delay(2);
if (tft.getTouchRawZ() < TCAL_Z_MIN)
return;
tft.getTouchRaw(&xb, &yb);
#else
xa = first.xRaw;
ya = first.yRaw;
delay(2);
TouchPoint second = touchscreen.getTouch();
if (second.zRaw < TCAL_Z_MIN)
return;
xb = second.xRaw;
yb = second.yRaw;
#endif
if (abs((int)xa - (int)xb) > TCAL_RAW_ERR ||
abs((int)ya - (int)yb) > TCAL_RAW_ERR)
return;
touchCalRaw[touchCalCorner * 2] += xa;
touchCalRaw[touchCalCorner * 2 + 1] += ya;
if (++touchCalSampleCount < TCAL_SAMPLES)
return;
touchCalRaw[touchCalCorner * 2] /= TCAL_SAMPLES;
touchCalRaw[touchCalCorner * 2 + 1] /= TCAL_SAMPLES;
if (++touchCalCorner >= 4)
{
finishTouchCalibration();
return;
}
drawTouchCalArrow(touchCalCorner - 1, clockBackgroundColor);
drawTouchCalArrow(touchCalCorner, TFT_GREEN);
drawTouchCalProgress();
touchCalSampleCount = 0;
touchCalWaitRelease = true;
touchCalCornerStart = millis();
}
void openTouchCalibration()
{
// Corner raw values only line up with pixels in the board's normal
// rotation - readTouchPoint() mirrors flipped displays on top of the
// stored mapping, so calibrating under the flipped rotation would flip
// touches twice. Flipped mounts see this one screen upside down.
tft.setRotation(BOARD_TFT_ROTATION_NORMAL);
touchCalCorner = 0;
touchCalSampleCount = 0;
for (int i = 0; i < 8; i++)
{
touchCalRaw[i] = 0;
}
touchCalWaitRelease = true;
touchCalCornerStart = millis();
switchToScreen(SCREEN_TOUCH_CAL);
}
/*-------- Boot-time settings button + boot console ----------*/
// See uiPages.h: a Settings button on the "System initializing..." screen so
// the blocking boot waits can be cut short on networks with no usable
// internet (e.g. login-required WiFi), landing the main loop directly on the
// settings page.
//
// The rest of that screen is a boot console: it mirrors the newest log lines
// (WiFi attempts with SSID and failure reason, portal, NTP, timezone setup)
// so the boot never looks frozen and failures are diagnosable on the device.
const UIButton BTN_BOOT_SETTINGS = {90, 192, 140, 32};
static bool bootUiActive = false; // button drawn, polling enabled
static bool bootSettingsWanted = false; // sticky once the button is tapped
// Console geometry: font 1 (6x8 px) rows between the title rule (y=38, drawn
// in displaySetup below the title + version line) and the Settings button
// (y=192).
static const int BOOT_CON_TOP = 42;
static const int BOOT_CON_LINE_H = 8;
static const int BOOT_CON_ROWS = 18;
static String bootConShown[BOOT_CON_ROWS]; // what each row currently displays
static uint32_t bootConLastBytes = 0;
static unsigned long bootConLastDraw = 0;
static int bootConsoleTop()
{
return scaleUiY(BOOT_CON_TOP);
}
static int bootConsoleLineH()
{
return max(BOOT_CON_LINE_H, scaleUiY(BOOT_CON_LINE_H));
}
static int bootConsoleCols()
{
return uiTextColsFromX(scaleUiX(2));
}
// Mirror the tail of the log ring into the console area. Rows are cached and
// only repainted when their text changes, so the frequent polls stay cheap
// and the screen doesn't flicker. force repaints everything (after another
// page wiped the screen).
static void bootConsoleRender(bool force)
{
if (!bootUiActive)
return;
uint32_t bytes = logBytesWritten();
if (!force)
{
if (bytes == bootConLastBytes)
return;
// Throttle repaints; the 50ms boot polls pick the change up shortly.
if (millis() - bootConLastDraw < 100)
return;
}
bootConLastBytes = bytes;
bootConLastDraw = millis();
// Generous byte budget so even long (later-truncated) lines still leave a
// full screen of rows - logTail starts at a line boundary and drops a
// clipped first line itself.
int cols = bootConsoleCols();
String tail = logTail(BOOT_CON_ROWS * (cols + 48));
// A trailing newline would show as a phantom empty last line; without it
// the last line is the newest (possibly still-forming) one.
if (tail.endsWith("\n"))
tail.remove(tail.length() - 1);
// Keep the last BOOT_CON_ROWS lines (ring over rows[], oldest overwritten).
String rows[BOOT_CON_ROWS];
int total = 0;
if (tail.length() > 0)
{
int start = 0;
while (start <= (int)tail.length())
{
int nl = tail.indexOf('\n', start);
int end = (nl < 0) ? tail.length() : nl;
rows[total % BOOT_CON_ROWS] = tail.substring(start, end);
total++;
if (nl < 0)
break;
start = nl + 1;
}
}
tft.setTextFont(1);
tft.setTextSize(1);
tft.setTextDatum(TL_DATUM);
tft.setTextColor(TFT_SILVER, clockBackgroundColor);
for (int i = 0; i < BOOT_CON_ROWS; i++)
{
int idx = total - BOOT_CON_ROWS + i; // log line shown on row i
String line = (idx >= 0) ? rows[idx % BOOT_CON_ROWS] : String();
if (line.length() > cols)
line = line.substring(0, cols);
if (!force && line == bootConShown[i])
continue;
bootConShown[i] = line;
int y = bootConsoleTop() + i * bootConsoleLineH();
tft.fillRect(0, y, screenWidth, bootConsoleLineH(), clockBackgroundColor);
tft.drawString(line, scaleUiX(2), y);
}
}
static void bootUiDrawChrome()
{
bool tapped = bootSettingsWanted;
drawButton(scaleUiButton(BTN_BOOT_SETTINGS), "Settings",
tapped ? TFT_GREEN : TFT_CYAN, tapped ? TFT_GREEN : TFT_WHITE);
tft.setTextFont(1);
tft.setTextSize(1);
tft.setTextDatum(TC_DATUM);
tft.setTextColor(TFT_DARKGREY, clockBackgroundColor);
tft.drawString("WiFi login / status / logs", screenWidth / 2,
screenHeight - scaleUiY(10));
}
void bootUiBegin()
{
#if BOARD_TOUCH_DRIVER == BOARD_TOUCH_DRIVER_BITBANG
// The touch controller normally starts later (rollingClockSetup), but
// begin() only sets pin modes, so starting it early here is harmless.
// On shared-SPI boards this would steal the LCD's SPI pins, so those use
// TFT_eSPI touch and need no separate begin call.
touchscreen.begin();
#endif
bootUiActive = true;
bootUiDrawChrome();
bootConsoleRender(true); // show the lines logged before the display came up
}
void bootUiRefresh()
{
if (!bootUiActive)
return;
// Another page (conf-mode screen, SetupCYD) painted over the boot screen:
// rebuild it wholesale, title included.
tft.fillScreen(clockBackgroundColor);
tft.setTextFont(2);
tft.setTextSize(1);
tft.setTextDatum(TC_DATUM);
tft.setTextColor(TFT_WHITE, clockBackgroundColor);
tft.drawString("System initializing...", screenWidth / 2, scaleUiY(2));
// Device identity under the title - see displaySetup.
tft.setTextFont(1);
tft.setTextColor(TFT_CYAN, clockBackgroundColor);
tft.drawString(deviceLabel(), screenWidth / 2, scaleUiY(18));
tft.setTextColor(TFT_DARKGREY, clockBackgroundColor);
tft.drawString(deviceMacAddress(), screenWidth / 2, scaleUiY(28));
tft.drawFastHLine(0, scaleUiY(38), screenWidth, TFT_DARKGREY);
bootUiDrawChrome();
bootConsoleRender(true);
}
void bootUiEnd()
{
bootUiActive = false;
}
bool bootUiSettingsRequested()
{
return bootSettingsWanted;
}
bool bootUiPoll()
{
if (!bootUiActive)
return bootSettingsWanted;
bootConsoleRender(false); // mirror any new log lines onto the screen
if (bootSettingsWanted)
return true;
TouchPoint t = readTouchPoint();
if (t.zRaw > 800 && buttonContains(scaleUiButton(BTN_BOOT_SETTINGS), t.x, t.y))
{
bootSettingsWanted = true;
// Acknowledge right away - the remaining boot steps can still take a
// few seconds before the settings page actually appears.
drawButton(scaleUiButton(BTN_BOOT_SETTINGS), "Settings", TFT_GREEN, TFT_GREEN);
Log.println("Settings requested from the init screen - cutting the "
"remaining boot waits short");
bootConsoleRender(true);
}
return bootSettingsWanted;
}
/*-------- Boot-time Wi-Fi failure page ----------*/
// See uiPages.h: when credentials just entered in the config portal fail to
// join, the boot path records the details here and the main loop shows
// SCREEN_WIFI_FAIL instead of the old silent reboot loop.
static String wifiFailSsid;
static int wifiFailStatus = 0;
static bool wifiFailPending = false;
static bool wifiFailPreview = false; // opened via /api/screen with demo data
static unsigned long wifiFailShownAt = 0;
// Auto-reboot the failure page after this long untouched, so an unattended
// clock still runs the boot recovery sequence (portal included) on its own.
static const unsigned long WIFI_FAIL_AUTO_REBOOT_MS = 5UL * 60UL * 1000UL;
void bootReportWifiFailure(const String &ssid, int wlStatus)
{
wifiFailSsid = ssid;
wifiFailStatus = wlStatus;
wifiFailPreview = false;
wifiFailPending = true;
// Cut the remaining boot network waits short, same as a Settings tap on
// the init screen; bootOpenPendingScreen then opens the failure page.
bootSettingsWanted = true;
}
void bootOpenPendingScreen()
{
// Boot is over: the main loop owns the screen from here (clock or one of
// the pages below); stop the console/button so they can't paint over it.
bootUiEnd();
if (wifiFailPending)
{
wifiFailPending = false;
switchToScreen(SCREEN_WIFI_FAIL);
}
else if (bootSettingsWanted)
{
switchToScreen(SCREEN_SETTINGS);
}
}
// One-line diagnosis + one-line detail for a wl_status_t join result.
static void wifiFailReason(int st, const char *&problem, const char *&detail)
{
switch (st)
{
case WL_NO_SSID_AVAIL:
problem = "network not found";
detail = "Out of range, hidden, or name typo.";
break;
case WL_CONNECT_FAILED:
problem = "join rejected";
detail = "Almost always a wrong password.";
break;
case WL_CONNECTION_LOST:
problem = "connection lost mid-join";
detail = "Weak signal? Move nearer the router.";
break;
default:
problem = "no response";
detail = "Bad password, weak signal or filter.";
break;
}
}
void renderWifiFailPage()
{
tft.fillScreen(clockBackgroundColor);
tft.setTextFont(4);
tft.setTextSize(1);
tft.setTextDatum(TC_DATUM);
tft.setTextColor(TFT_RED, clockBackgroundColor);
tft.drawString("WIFI CONNECT FAILED", screenWidth / 2, scaleUiY(2));
const char *problem, *detail;
wifiFailReason(wifiFailStatus, problem, detail);
String ssid = wifiFailSsid.length() ? wifiFailSsid : "(no network name)";
if (ssid.length() > 24) ssid = ssid.substring(0, 24);
tft.setTextFont(2);
tft.setTextDatum(TL_DATUM);
tft.setTextColor(TFT_LIGHTGREY, clockBackgroundColor);
tft.drawString("Network:", scaleUiX(12), scaleUiY(36));
tft.setTextColor(TFT_YELLOW, clockBackgroundColor);
tft.drawString(ssid, scaleUiX(90), scaleUiY(36));
tft.setTextColor(TFT_LIGHTGREY, clockBackgroundColor);
tft.drawString("Problem:", scaleUiX(12), scaleUiY(56));
tft.setTextColor(TFT_WHITE, clockBackgroundColor);
tft.drawString(String(problem) + " (" + String(wifiFailStatus) + ")",
scaleUiX(90), scaleUiY(56));
tft.setTextColor(TFT_LIGHTGREY, clockBackgroundColor);
tft.drawString(detail, scaleUiX(12), scaleUiY(76));
tft.drawString("Reboot: try again now (the setup", scaleUiX(12), scaleUiY(104));
tft.drawString("portal reopens if it fails again).", scaleUiX(12), scaleUiY(120));
tft.drawString("Settings: open status & logs; WiFi", scaleUiX(12), scaleUiY(144));
tft.drawString("keeps retrying in the background.", scaleUiX(12), scaleUiY(160));
drawButton(scaleUiButton(BTN_FAIL_REBOOT), "Reboot", TFT_RED, TFT_WHITE);
drawButton(scaleUiButton(BTN_FAIL_SET), "Settings", TFT_CYAN, TFT_WHITE);
tft.setTextFont(1);
tft.setTextDatum(TC_DATUM);
tft.setTextColor(TFT_DARKGREY, clockBackgroundColor);
if (wifiFailPreview)
{
// Seeded demo details, opened via /api/screen: make sure a captured
// screenshot can't be mistaken for a real outage, and don't let the
// unattended-recovery reboot fire under a developer's feet.
tft.setTextColor(TFT_ORANGE, clockBackgroundColor);
tft.drawString("PREVIEW - demo data, auto-reboot off", screenWidth / 2, scaleUiY(232));
}
else
{
tft.drawString("reboots by itself after 5 minutes", screenWidth / 2, scaleUiY(232));
}
wifiFailShownAt = millis();
}
/*-------- Settings actions ----------*/
void saveDisplayPrefs()
{
projectConfig.twentyFourHour = SHOW_24HOUR;
projectConfig.usDateFormat = !NOT_US_DATE;
projectConfig.saveConfigFile();
}
void drawSettingsBrightnessLabel()
{
UIButton b = settingsButton(SETTINGS_BRIGHTNESS_LABEL);
tft.fillRect(b.x, b.y, b.w, b.h, clockBackgroundColor);
tft.setTextFont(2);
tft.setTextSize(1);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_WHITE, clockBackgroundColor);
int pct = brightnessPercent(backlightLevel);
tft.drawString("Brightness " + String(pct) + "%", b.x + b.w / 2, b.y + b.h / 2);
}
void adjustBacklightFromUi(int delta)
{
backlightLevel = clampBrightness(backlightLevel + delta);
setBacklight(backlightLevel);
// Hold this manual setting before auto-brightness resumes
markManualBrightness();
// Persist so the level survives a reboot (taps are discrete, so this
// stays well within SPIFFS write-endurance territory)
projectConfig.brightness = backlightLevel;
projectConfig.saveConfigFile();
drawSettingsBrightnessLabel();
Log.println("Brightness set from settings page: " + String(backlightLevel));
}
// Apply a timezone preset to a quadrant, persist it, and re-fetch the zone
// definition from the ezTime server (brief blocking network call). Declared
// in uiPages.h - also used by the web settings page.
void applyZoneSelection(int slot, const TimezonePreset &preset)
{
tft.fillScreen(clockBackgroundColor);
tft.setTextFont(2);
tft.setTextSize(1);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_CYAN, clockBackgroundColor);
tft.drawString("Loading " + String(preset.name) + "...",
screenWidth / 2, screenHeight / 2);
worldZones[slot].name = preset.name;
worldZones[slot].timezone = preset.tz;
worldZones[slot].market = getMarketInfoForTimezone(preset.tz);
worldZones[slot].lastMarketStatus = "";
worldZones[slot].lastHour = -1;
worldZones[slot].lastMinute = -1;
worldZones[slot].lastDay = -1;
worldZones[slot].initialized = false;
worldZones[slot].weatherAlert = "";
worldZones[slot].weatherNotice = "";
// Start from a clean ezTime object when a slot changes. Reusing the old
// object can leave the previous city's rules alive if a rapid sequence of
// timezone-server lookups gets a stale UDP response.
worldZones[slot].tz = Timezone();
bool tzReady = false;
if (worldZones[slot].tz.setCache(slot * EEPROM_CACHE_LEN) &&
zoneRulesMatch(worldZones[slot], preset.tz))
{
tzReady = true;
}
if (!tzReady)
{
bool fetched = worldZones[slot].tz.setLocation(preset.tz);
if (!(fetched && zoneRulesMatch(worldZones[slot], preset.tz)))
{
String got = fetched ? worldZones[slot].tz.getOlson() : String("");
if (got.length() > 0)
{
Log.println("Timezone lookup for " + String(preset.tz) +