Repository navigation
Expand file tree
/
Copy pathmain.cpp
More file actions
7223 lines (6945 loc) · 324 KB
/
Copy pathmain.cpp
File metadata and controls
7223 lines (6945 loc) · 324 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
// SD PRO custom firmware
// Themes: Clock + Crypto ticker (price, change, candle/line chart)
// Data: CoinGecko (HTTPS). Config: LittleFS /config.json. OTA: /update
#include <Arduino.h>
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <WiFiUdp.h>
#include <ESP8266HTTPClient.h>
#include <Updater.h>
#include <StreamString.h>
#include <WiFiClientSecureBearSSL.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <TFT_eSPI.h>
#include <time.h>
#include <lwip/dns.h>
#include "icons.h"
#include "anim_all.h"
#include "boot_winxp.h"
#include "assets_wx.h"
#include "flash_assets.h"
#include <TJpg_Decoder.h>
#include "coins_local.h"
#include "cal_ru.h"
#include "font_ru.h"
#include "page_gz.h" // generated by tools/mkpagegz.py from the raw literals
static TFT_eSPI tft;
static ESP8266WebServer server(80);
// ---------------------------------------------------------------- palette
static uint16_t C_BG = 0x0822; // screen background, runtime-tunable (cfg.bgcolor)
#define C_PANEL 0x10C6 // chart panel
#define C_BORDER 0x2947 // borders
#define C_GRID 0x18E3 // grid lines
#define C_HDR 0x1084 // header bar
#define C_PRICE_BG 0xFFE0 // yellow price box
#define C_PRICE_FG 0x0000 // black on yellow
#define C_UP 0x07E0 // green
#define C_DOWN 0xF800 // red
#define C_UP_D 0x03E0 // dark green: badge background (white text stays readable)
#define C_DOWN_D 0x9000 // dark red: badge background
#define C_ORANGE 0xFD20 // stock: minutes / seconds / city
#define C_MON_P 0xFA75 // pastel red (date month)
#define C_LINE 0xFB06 // orange line
#define C_TXT 0xEF7D // light grey
#define C_GREY 0x632C // neutral grey ~ (100,100,100), subdued but readable
#define C_DIM 0x6B4D // dim grey
#define C_CYAN 0x07FF
#define C_WHITE 0xFFFF
#define C_CLOUD 0x8C51 // cloud fill
#define C_MIST 0x528A // fog lines
// ---------------------------------------------------------------- config
struct Cfg {
String theme = "ticker"; // current theme key
String enabled = "classic,flip,weather,forecast,ticker,crypto3,calendar,photo"; // all themes on first boot
bool carousel= true;
uint16_t interval = 45; // seconds between themes
String sym = "bitcoin";
String range = "1d"; // 1d | 7d (30d removed: CoinGecko origin too slow for its body)
String chart = "candle"; // candle | line
uint8_t brightness = 100; // 0..100 percent (stock scale)
uint16_t refresh = 60; // data refresh seconds
String tz = "MSK-3"; // POSIX TZ
String city = ""; // empty by default: picked via autocomplete/My location
String syms = "bitcoin,ethereum,solana"; // up to 3 ids for "Crypto all"
uint8_t gifnum = 0; // classic corner animation 0..3
uint16_t bgcolor = 0x0822; // RGB565 screen background
String wifiSsid; // saved network for auto-connect
String wifiPass;
bool hol = false; // highlight state holidays (calendar theme)
String hctry; // ISO-3166 alpha-2 country ("" = auto-detect via geo)
bool nmode = false; // night backlight window enabled
String nfrom = "23:00"; // HH:MM - night starts
String nto = "07:00"; // HH:MM - night ends (may cross midnight)
uint8_t nbr = 20; // night brightness percent (0..100)
String tfmt = "24"; // clock format: "24" | "12" (AM/PM)
String lang = "en"; // UI language: "ru" | "en" | "es" | "zh" (default English)
String wprov = "ometo"; // weather source: "owm" | "ometo" (ometo needs no key)
String owmKey; // OpenWeatherMap API key, user-entered ("" = none)
String cgKey; // optional CoinGecko Demo API key ("" = free public)
} cfg;
// 0 = ru, 1 = en, 2 = es, 3 = zh
static inline int langIdx(){
if(cfg.lang == "en") return 1;
if(cfg.lang == "es") return 2;
if(cfg.lang == "zh") return 3;
return 0;
}
// true when the Russian UI is selected (content decisions)
static inline bool ru(){ return cfg.lang == "ru"; }
// true when text must go through the custom FontRu renderer: every language
// except EN (the built-in GFX fonts carry ASCII only, so es/zh need FontRu)
static inline bool gfxFont(){ return cfg.lang != "en"; }
// pick a string by language: ru, en, es, zh. PSTR keeps all four literals in
// flash (irom0): plain literals land in .rodata/DRAM, and the es/zh additions
// pushed the fetch-time free heap under the 9000 gate.
#define LG(r,e,s,z) pickLangL(PSTR(r),PSTR(e),PSTR(s),PSTR(z))
static inline const char* pickLangL(const char* r, const char* e,
const char* s, const char* z){
switch(langIdx()){ case 1: return e; case 2: return s; case 3: return z; default: return r; }
}
// weather/geocoder API language suffix for the selected language ("" for EN)
static inline const char* langQ(){
if(cfg.lang == "ru") return "&lang=ru";
if(cfg.lang == "es") return "&lang=es";
if(cfg.lang == "zh") return "&lang=zh_cn";
return "";
}
// bare Open-Meteo geocoder language code
static inline const char* geoLang(){
if(cfg.lang == "ru") return "ru";
if(cfg.lang == "es") return "es";
if(cfg.lang == "zh") return "zh";
return "en";
}
// IANA zone (ip-api) -> POSIX TZ. No DST rules: a fixed offset is fine for
// a desk clock, the dropdown still offers full control afterwards.
static const char* posixFromIana(const String& iana, int offSec){
static const char* T[][2] = {
{"Europe/Minsk","MSK-3"},{"Europe/Kaliningrad","EET-2"},{"Europe/Moscow","MSK-3"},
{"Europe/Kyiv","EET-2"},{"Europe/Kiev","EET-2"},{"Europe/Warsaw","CET-1"},
{"Europe/Berlin","CET-1"},{"Europe/Paris","CET-1"},{"Europe/Madrid","CET-1"},
{"Europe/Rome","CET-1"},{"Europe/Belgrade","CET-1"},{"Europe/Istanbul","+03"},
{"Europe/Athens","EET-2"},{"Europe/Bucharest","EET-2"},{"Europe/Lisbon","WET0"},
{"Europe/London","GMT0"},{"Asia/Almaty","ALMT-6"},{"Asia/Yekaterinburg","+05"},
{"Asia/Yerevan","+04"},{"Asia/Baku","+04"},{"Asia/Tbilisi","+04"},
{"Asia/Omsk","+06"},{"Asia/Novosibirsk","+07"},{"Asia/Krasnoyarsk","+07"},
{"Asia/Irkutsk","+08"},{"Asia/Yakutsk","+09"},{"Asia/Vladivostok","+10"},
{"Asia/Jerusalem","IST-2"},{nullptr,nullptr}
};
if(iana.length()){
for(int i = 0; T[i][0]; i++) if(iana == T[i][0]) return T[i][1];
}
int o = offSec / 3600; // fall back to the raw offset
static char b[12];
if(o == 0) return "UTC0";
snprintf(b, sizeof(b), "%s%d", o > 0 ? "UTC-" : "UTC+", o > 0 ? o : -o);
return b;
}
// BigDataCloud reverse-geocoder language (falls back to en for anything else)
static inline String geoLocalityLang(){
if(cfg.lang == "ru" || cfg.lang == "es" || cfg.lang == "zh") return String(cfg.lang);
return String("en");
}
// OWM local_names key used by the fallback geocoder
static inline const char* owmLocKey(){
if(cfg.lang == "es") return "es";
if(cfg.lang == "zh") return "zh_cn";
return "ru";
}
// cut a String to n bytes without splitting a UTF-8 sequence (Cyrillic = 2 bytes)
static String cutB(const String& s, size_t n){
if(s.length() <= n) return s;
size_t e = n;
while(e > 0 && ((uint8_t)s[e] & 0xC0) == 0x80) e--;
return s.substring(0, e);
}
// fetch errors are written in English -> show them in the selected language,
// both on the TFT (loading/waiting slots) and in /api/status
static String errLoc(const String& e){
if(cfg.lang != "en" && e.length()){
// all four languages in flash: the kv struct + strings used to sit in
// .rodata/DRAM, and the es/zh additions pushed the fetch gate under 9000
static const char KE[][20] PROGMEM __attribute__((aligned(4))) = {
"no data", "no quotes", "empty body", "empty", "rate limited (429)", "dns timeout",
"begin failed", "no memory (oom)", "geocode", "fc geocode", "fc parse", "fc no list", "fc oom",
"ohlc empty", "empty ohlc", "ohlc oom", "ohlc eof", "ohlc truncated", "holidays empty",
"holidays parse", "connection refused", "connection lost", "connection failed",
"connection closed", "stream disconnected", "no response", "read timeout", "timeout",
"unknown error", "no city",
};
static const char KR[][36] PROGMEM __attribute__((aligned(4))) = {
"нет данных", "нет котировок", "пустой ответ", "пусто", "лимит (429)", "таймаут DNS",
"нет соединения", "не хватает памяти", "геокодер", "геокодер", "прогноз: разбор",
"прогноз: пусто", "прогноз: память", "нет свечей", "нет свечей", "свечи: память", "свечи: обрыв",
"свечи: обрезаны", "праздники: пусто", "праздники: ошибка", "нет соединения", "обрыв соединения",
"обрыв связи", "связь закрыта", "поток оборван", "нет ответа", "таймаут чтения", "таймаут",
"неизвестная ошибка", "город не задан",
};
static const char KS[][28] PROGMEM __attribute__((aligned(4))) = {
"sin datos", "sin cotizaciones", "cuerpo vacío", "vacío", "límite (429)", "tiempo de espera DNS",
"sin conexión", "sin memoria", "geocodificador", "geocodificador", "análisis del pronóstico",
"pronóstico: vacío", "pronóstico: memoria", "sin velas", "sin velas", "velas: memoria",
"velas: corte", "velas: truncadas", "festivos: vacío", "festivos: error", "conexión rechazada",
"conexión perdida", "fallo de conexión", "conexión cerrada", "flujo cortado", "sin respuesta",
"tiempo de espera de lectura", "tiempo de espera", "error desconocido", "sin ciudad",
};
static const char KZ[][16] PROGMEM __attribute__((aligned(4))) = {
"无数据", "无报价", "空响应", "空", "限流 (429)", "DNS 超时", "无连接", "内存不足", "地理编码", "地理编码", "预报: 解析", "预报: 空",
"预报: 内存", "无K线", "无K线", "K线: 内存", "K线: 中断", "K线: 截断", "假日: 空", "假日: 错误", "连接被拒", "连接丢失", "连接失败",
"连接关闭", "流中断", "无响应", "读取超时", "超时", "未知错误", "未设置城市",
};
for(size_t i = 0; i < sizeof(KE) / sizeof(KE[0]); i++){
// ROM strcmp byte-loads its irom operand -> LoadStoreError on KE[i];
// copy the row to the stack first (memcpy_P is word-based, proven safe)
char row[sizeof(KE[0])];
memcpy_P(row, KE[i], sizeof(row));
if(e == row){
if(langIdx() == 2) return String((const char*)KS[i]);
if(langIdx() == 3) return String((const char*)KZ[i]);
return String((const char*)KR[i]);
}
}
if(e.startsWith("json ")) return String("JSON: ") + e.substring(5);
if(e.startsWith("fc http ")) return String(LG("прогноз: HTTP", "fc HTTP",
"pronóstico: HTTP", "预报: HTTP")) + e.substring(8);
}
return e;
}
// ---------------------------------------------------------------- themes
struct ThemeDef { const char* key; const char* name; };
static const ThemeDef THEMES[] = {
{"classic", "Classic"},
{"flip", "Flip Clock"},
{"weather", "Weather"},
{"forecast", "Weather forecast"},
{"ticker", "Crypto ticker"},
{"crypto3", "Crypto all"},
{"calendar", "Calendar"},
{"photo", "Photo"},
};
static const int THEME_N = sizeof(THEMES) / sizeof(THEMES[0]);
static const char THEME_RU[][24] PROGMEM __attribute__((aligned(4))) = {
"Классика", "Flip-часы", "Погода", "Прогноз", "Крипто-тикер", "Все крипто", "Календарь", "Фото",
};
static const char THEME_ES[][16] PROGMEM __attribute__((aligned(4))) = {
"Clásico", "Reloj Flip", "Clima", "Pronóstico", "Ticker cripto", "Todo cripto", "Calendario",
"Foto",
};
static const char THEME_ZH[][16] PROGMEM __attribute__((aligned(4))) = {
"经典", "翻页时钟", "天气", "预报", "加密行情", "全部加密", "日历", "照片",
};
static const char* themeName(int i){
switch(langIdx()){ case 1: return THEMES[i].name; case 2: return THEME_ES[i];
case 3: return THEME_ZH[i]; default: return THEME_RU[i]; }
}
static const char RU_WDF[][24] PROGMEM __attribute__((aligned(4))) = {
"Воскресенье", "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота",
};
static const char EN_WDF[][12] PROGMEM __attribute__((aligned(4))) = {
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday",
};
static const char ES_WDF[][12] PROGMEM __attribute__((aligned(4))) = {
"Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado",
};
static const char ZH_WDF[][8] PROGMEM __attribute__((aligned(4))) = {
"周日", "周一", "周二", "周三", "周四", "周五", "周六",
};
static const char RUS_WDF[][8] PROGMEM __attribute__((aligned(4))) = {
"Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб",
}; // short: forecast rows
static const char ENS_WDF[][4] PROGMEM __attribute__((aligned(4))) = {
"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat",
};
static const char ESS_WDF[][8] PROGMEM __attribute__((aligned(4))) = {
"dom", "lun", "mar", "mié", "jue", "vie", "sáb",
};
static const char ZHS_WDF[][4] PROGMEM __attribute__((aligned(4))) = {
"日", "一", "二", "三", "四", "五", "六",
};
static const char RU_MON[][8] PROGMEM __attribute__((aligned(4))) = {
"янв", "фев", "мар", "апр", "мая", "июн", "июл", "авг", "сен", "окт", "ноя", "дек",
};
static const char ENS_MON[][4] PROGMEM __attribute__((aligned(4))) = {
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
};
static const char ESS_MON[][4] PROGMEM __attribute__((aligned(4))) = {
"ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic",
};
static const char ZHS_MON[][8] PROGMEM __attribute__((aligned(4))) = {
"1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月",
};
static inline const char* dayLong(int i){
switch(langIdx()){ case 1: return EN_WDF[i]; case 2: return ES_WDF[i];
case 3: return ZH_WDF[i]; default: return RU_WDF[i]; }
}
static inline const char* dayShort(int i){
switch(langIdx()){ case 1: return ENS_WDF[i]; case 2: return ESS_WDF[i];
case 3: return ZHS_WDF[i]; default: return RUS_WDF[i]; }
}
static inline const char* monShort(int i){
switch(langIdx()){ case 1: return ENS_MON[i]; case 2: return ESS_MON[i];
case 3: return ZHS_MON[i]; default: return RU_MON[i]; }
}
// "Today" / "Tomorrow" / short weekday for the forecast rows (n = 1..5)
static String fcDayName(int n, int wd){
if(n == 1) return String(LG("Сегодня", "Today", "Hoy", "今天"));
if(n == 2) return String(LG("Завтра", "Tomorrow", "Mañana", "明天"));
return String(dayShort(wd));
}
static bool themeExists(const String& k){
for(int i = 0; i < THEME_N; i++) if(k == THEMES[i].key) return true;
return false;
}
static bool themeEnabled(const String& k){
// no temporary ","+cfg.enabled+"," String: this runs from the 5s status poll
// and every render, and the concat churned the heap for no reason
int p = cfg.enabled.indexOf(k, 0);
while(p >= 0){
bool lok = (p == 0) || cfg.enabled[p - 1] == ',';
bool rok = (p + (int)k.length() == (int)cfg.enabled.length()) ||
cfg.enabled[p + k.length()] == ',';
if(lok && rok) return true;
p = cfg.enabled.indexOf(k, p + 1);
}
return false;
}
// the carousel gate calls this on EVERY loop() pass (thousands/sec): the
// CSV scan is cheap but not free at that rate - recompute only after
// cfg.enabled changes (loadCfg sanitize + handleSet theme/themes)
static int enCountCache = -1;
static void enCountInvalidate(){ enCountCache = -1; }
static int enabledCount(){
if(enCountCache < 0){
int n = 0;
for(int i = 0; i < THEME_N; i++) if(themeEnabled(THEMES[i].key)) n++;
enCountCache = n;
}
return enCountCache;
}
static void nextEnabledTheme(){
int start = -1;
for(int i = 0; i < THEME_N; i++) if(cfg.theme == THEMES[i].key){ start = i; break; }
for(int s = 1; s <= THEME_N; s++){
int i = (start + s + THEME_N) % THEME_N;
if(themeEnabled(THEMES[i].key)){
cfg.theme = THEMES[i].key;
return;
}
}
}
// redraw mask: full clear only when the theme itself changes
#define RM_FULL 0x01
#define RM_HEAD 0x02
#define RM_PRICE 0x04
#define RM_CHART 0x08
#define RM_FOOT 0x10
#define RM_SEC 0x20 // seconds only (classic) - no full-region repaint
#define RM_ALL 0x3F
static uint8_t rm = RM_ALL;
static bool dirty = true;
static void mark(uint8_t m){ rm |= m; dirty = true; }
static const char* SYMS[][2] = {
{"bitcoin","BTC"}, {"ethereum","ETH"}, {"solana","SOL"}, {"cardano","ADA"},
{"dogecoin","DOGE"}, {"ripple","XRP"}, {"litecoin","LTC"}, {"binancecoin","BNB"},
{"tron","TRX"}, {"avalanche-2","AVAX"}, {"polkadot","DOT"}, {"chainlink","LINK"}
};
static const int SYM_N = sizeof(SYMS)/sizeof(SYMS[0]);
static String labelOf(const String& id){
for(int i=0;i<SYM_N;i++) if(id==SYMS[i][0]) return SYMS[i][1];
for(uint16_t i = 0; i < COINS_LOCAL_N; i++){ // full catalog fallback
CoinRef c;
memcpy_P(&c, &COINS_LOCAL[i], sizeof(c));
if(c.sym && id.equals(c.id)){ String s(c.sym); s.toUpperCase(); return s; }
}
return id;
}
// ---------------------------------------------------------------- market
#define MAXCD 48
#define MAXLN 120
static struct { float o,h,l,c; } cds[MAXCD];
static int cdN = 0;
static float lns[MAXLN];
static int lnN = 0;
static float mkPrice = 0, mkChg = 0;
static bool mkOk = false;
static uint32_t mkAt = 0; // millis() of last good fetch
static uint32_t lastFetchTry = 0;
static uint32_t lastTlsTry = 0; // last httpGetStart entry (breaker: fetchers alive?)
static uint32_t coolUntil = 0; // backoff after HTTP 429 / OOM
static uint8_t oomN = 0; // consecutive BearSSL OOM failures
static uint32_t lastHttpMs = 0; // duration of the last connect+GET (diagnostics)
static const char* lastPhase = "-"; // which fetch ran last, and how long it took
static uint32_t lastPhaseMs = 0;
static int cdLen = 0; // OHLC payload diagnostics
static bool cdChunk = false;
static String mkErr;
static bool httpSkip = false; // last attempt was DEFERRED (heap gate / NTP wait),
// not a real failure: callers keep the old state
static String mkErrReal; // last REAL market-path failure (price/candles/quotes).
// mkErr is shared with weather/geocode and must not
// paint the crypto line red for their errors
static uint32_t bootMs = 0;
// TLS spacing: back-to-back sessions shred the heap (4s), but the first
// 90s after boot have the healthiest heap of the whole uptime and every
// panel is empty on a clean install - 2s there fills all of them ~8s sooner
static uint32_t coolGap(){
return (millis() - bootMs < 90000UL) ? 2000UL : 4000UL;
}
static uint32_t themeSince = 0;
// persistent cache (LittleFS): the last good payload survives reboots, so the
// screen paints real data immediately and boot needs no CoinGecko round trip
static bool cacheLoadAll();
static void cacheSaveWx();
static void cacheSaveFc();
static void cacheSaveQ3();
// ---------------------------------------------------------------- helpers
static inline int yOf(float v, float lo, float hi, int y, int h){
if(hi <= lo) return y + h/2;
float t = (v - lo) / (hi - lo);
if(t < 0) t = 0;
if(t > 1) t = 1;
return y + h - 1 - (int)(t * (h - 1));
}
static String trimZeros(const String& s){
int dot = s.indexOf('.');
if(dot < 0) return s;
int last = s.length() - 1;
while(last > dot + 2 && s[last] == '0') last--; // keep 2 decimals minimum
return s.substring(0, last + 1);
}
// decimals needed for ~4 significant digits on sub-unit prices - matches how
// CoinGecko itself renders (0.04875, NOT the raw API float 0.0487523 with
// 7 decimals - the panel used to print every digit the API returned).
// Old m+6 gave 7 decimals for a 0.048-coin; m+4 still keeps tiny prices
// readable (0.00000429 stays 0.00000429) without the absurd tails.
static int decFor(float p){
int m = 0;
float q = p;
while(q < 0.1f && m < 8){ q *= 10.0f; m++; }
int d = m + 4; // 4 significant digits (0.04875, not 0.0487523)
return d > 9 ? 9 : d;
}
static String fmtPrice(float p){
if(p >= 1000000) return String(p,0);
if(p >= 1000) return String(p,0);
if(p >= 10) return trimZeros(String(p,2));
if(p >= 1) return trimZeros(String(p,3));
return trimZeros(String(p, decFor(p)));
}
// price for the big panel: most precise candidate that still fits, tried on
// font 7 first (the 7-seg panel keeps its 6-char rule) and then on the
// narrower fonts - a tiny price like 0.00000429 steps down a font instead of
// collapsing to "0.00".
static String priceBig(float p, uint8_t& fontOut){
fontOut = 7;
if(!(p > 0.0f)) return "--";
// sub-unit prices show their FULL precision - the font steps down instead
// of the decimals (0.05233 must stay 0.05233, not shrink to "0.0523").
// The font is picked from the UNTRIMMED worst-case width: picking it from
// the trimmed string made a trailing zero flip the length (0.0485 vs
// 0.04856) and the font jumped 7<->6 every other tick on the same coin.
if(p < 1.0f){
String full = String(p, decFor(p));
const uint8_t pref[3] = {7, 6, 4};
fontOut = 4;
for(int f = 0; f < 3; f++)
if(tft.textWidth(full, pref[f]) <= 168){ fontOut = pref[f]; break; }
return trimZeros(full);
}
// PANEL FONT RULE (experimentally tuned on the real device, widths are
// exposed as pw4/pw6/pw7 in /api/status, panel = 168px):
// >=1 keeps the original rule - most precise decimal that fits font7.
int maxD = 5, minD = 0;
const uint8_t pref[3] = {7, 6, 4};
for(int f = 0; f < 3; f++)
for(int d = maxD; d >= minD; d--){
String s = trimZeros(String(p, d));
if(tft.textWidth(s, pref[f]) <= 168){ fontOut = pref[f]; return s; }
}
// nothing fit (13+ chars): full precision on the smallest font anyway
fontOut = 4;
return trimZeros(String(p, 2));
}
static String two(int v){ return v<10 ? String("0")+String(v) : String(v); }
// 12-hour clock helpers (web select: 24 | 12)
static bool is12(){ return cfg.tfmt == "12"; }
static int hour12(int h){ return h % 12 ? h % 12 : 12; }
static const char* ampm(int h){ return h < 12 ? "AM" : "PM"; }
static bool timeOk(struct tm& ti){
time_t t = time(nullptr);
localtime_r(&t, &ti);
return ti.tm_year + 1900 >= 2020;
}
// ---------------------------------------------------------------- network
static void applyDns(){
ip_addr_t a, b;
IP_ADDR4(&a, 1, 1, 1, 1);
IP_ADDR4(&b, 9, 9, 9, 9);
dns_setserver(0, &a);
dns_setserver(1, &b);
}
// provisioning portal DNS: answer ONLY the connectivity-probe domains every OS
// uses to detect a captive portal (Android generate_204, Apple captive.apple.com,
// Firefox detectportal, Windows NCSI, ...) with the AP address, and NXDOMAIN for
// everything else. The naive wildcard ("*" -> AP ip) hijacked every background
// fetch/HSTS upgrade: the phone's captive view hopped to
// https://www.google.com/gen_204 and download managers saved html "files" from
// random CDNs before the user could pick a network.
static WiFiUDP dnsUdp;
static bool dnsPortalOn = false;
static bool dnsIsProbe(const String& h){
static const char* suf[] = {
"gstatic.com", "google.com", "g.cn", // Android / Chromium
"apple.com", // iOS / macOS
"firefox.com", // Firefox
"msftconnecttest.com", "msftncsi.com", // Windows NCSI
"android.com", "ubuntu.com" // Android alt / Ubuntu
};
for(unsigned i = 0; i < sizeof(suf) / sizeof(suf[0]); i++){
String s(suf[i]);
if(h == s || h.endsWith("." + s)) return true;
}
return false;
}
static void portalStart(){
if(dnsPortalOn || WiFi.getMode() == WIFI_STA) return;
if(!dnsUdp.begin(53)){
Serial.println("[wifi] captive DNS: udp bind failed");
return;
}
dnsPortalOn = true;
Serial.println("[wifi] captive DNS on (probe domains only)");
}
static void portalStop(){
if(!dnsPortalOn) return;
dnsUdp.stop();
dnsPortalOn = false;
Serial.println("[wifi] captive DNS off");
}
static void dnsPoll(){
if(!dnsPortalOn) return;
int n = dnsUdp.parsePacket();
if(n < 12 || n > 512) return;
uint8_t b[512];
if(dnsUdp.read(b, n) != n) return;
if(b[2] & 0x80) return; // not a query
if(((b[4] << 8) | b[5]) != 1) return; // single question only
String host;
int i = 12;
while(i < n){
uint8_t l = b[i++];
if(l == 0) break;
if(l > 63 || i + l > n) return;
if(host.length()) host += '.';
for(uint8_t k = 0; k < l; k++){
char c = (char)b[i + k];
host += (c >= 'A' && c <= 'Z') ? (char)(c + 32) : c;
}
i += l;
}
if(!host.length() || i + 4 > n) return;
int qEnd = i + 4; // end of the question
uint16_t qType = ((uint16_t)b[i] << 8) | b[i + 1];
bool hit = dnsIsProbe(host);
bool answer = hit && (qType == 1 || qType == 255); // A / ANY
uint8_t out[560];
memcpy(out, b, qEnd); // header + question only
out[2] = b[2] | 0xC4; // QR | AA | RD echo
out[3] = 0x80 | (hit ? 0 : 3); // RA, rcode NOERROR/NXDOMAIN
out[6] = 0; out[7] = answer ? 1 : 0; // ANCOUNT
out[8] = out[9] = out[10] = out[11] = 0; // NSCOUNT / ARCOUNT
int olen = qEnd;
if(answer){
static const uint8_t rec[12] = { 0xC0, 0x0C, 0x00, 0x01, 0x00, 0x01,
0x00, 0x00, 0x00, 0x00, 0x00, 0x04 };
memcpy(out + olen, rec, 12); olen += 12;
IPAddress ap = WiFi.softAPIP();
out[olen++] = ap[0]; out[olen++] = ap[1];
out[olen++] = ap[2]; out[olen++] = ap[3];
}
dnsUdp.beginPacket(dnsUdp.remoteIP(), dnsUdp.remotePort());
dnsUdp.write(out, olen);
dnsUdp.endPacket();
}
// "/api/v3/coins/..." tail of a URL, for error messages
static String urlPathTail(const String& u){
int h = u.indexOf("://");
if(h >= 0){
int p = u.indexOf('/', h + 3);
if(p >= 0) return u.substring(p);
}
return u;
}
// The webserver's accept path (WiFiServer::_accept, lwip sys context) does a
// plain operator new for every incoming connection. While a TLS session is
// open the engine holds the heap at ~2k, so a poller/browser SYN arriving in
// that window threw bad_alloc -> "Unhandled C++ exception: OOM" abort (seen
// as Software restarts at up=10-15s and mid-run). Close the listener for the
// whole fetch window (httpGetStart -> caller done) and reopen after; the
// flag makes double close/open a no-op and keeps handleGeoReverse's
// in-handler fetch from reopening early.
static bool srvDown = false;
static bool srvSkip = false; // in-handler fetch: closing would kill
// the client we still owe a response
static void srvClose(){ if(!srvDown){ server.stop(); srvDown = true; } }
static void srvOpen(){ if(srvDown){ server.begin(); srvDown = false; } }
// Quiet window after a link-up: no TLS from the loop fetchers while the DNS
// stack, the heap and the captive/portal page flow settle. A clean install
// used to fire candle+price+quotes ~2s after the STA came up: every fetch
// died as "dns timeout" or shredded the heap into minutes of "heap low".
static uint32_t fetchHoldUntil = 0;
static bool fetchHold(){
// a phone on the setup page: no TLS while the browser needs the server
if((WiFi.getMode() & WIFI_AP) && WiFi.softAPgetStationNum() > 0) return true;
if(!fetchHoldUntil) return false;
if(millis() >= fetchHoldUntil){ fetchHoldUntil = 0; return false; }
return true;
}
// shared TLS GET setup: configures the client, issues the request and handles
// the 429 backoff. Returns false with mkErr set; `code` carries the status.
static bool httpGetStart(BearSSL::WiFiClientSecure& cli, HTTPClient& http,
const String& url, int& code){
uint32_t t0 = millis();
httpSkip = false;
lastTlsTry = t0;
if(time(nullptr) < 1600000000UL){
// NTP not synced yet (cold install): every handshake fails on the cert
// validity window (observed fast "connection failed" loop for minutes
// until the fail-wedge reboot). Wait for the clock instead of burning
// attempts - the SDK retries NTP on its own.
static uint32_t nw = 0;
if(millis() - nw > 30000UL){ nw = millis(); Serial.println("[http] skip: no NTP time"); }
httpSkip = true;
mkErr = "no time";
coolUntil = millis() + 2000UL;
if(!srvSkip) srvOpen();
return false;
}
if(!srvSkip) srvClose(); // no accepts while the engine eats the heap
// pre-resolve DNS with a hard cap: the lookup inside WiFiClient::connect()
// has its own (much longer) timeout - a dropped router reply blocked
// loop() for ~19s (observed as heartbeat gaps; one crash landed inside
// dns_gethostbyname). The answer lands in the lwip cache, so the real
// connect below resolves instantly; a miss backs off instead of freezing.
{
int hs = url.indexOf("://");
String host = (hs >= 0) ? url.substring(hs + 3) : url;
int sl = host.indexOf('/');
if(sl >= 0) host = host.substring(0, sl);
if(host.length()){
IPAddress dip;
uint32_t td = millis();
if(WiFi.hostByName(host.c_str(), dip, 3000) <= 0){
delay(500); // link just up / heap shifted: one retry
if(WiFi.hostByName(host.c_str(), dip, 2500) <= 0){
mkErr = "dns timeout";
coolUntil = millis() + 5000UL;
Serial.printf("[dns] %s FAIL %lums\n", host.c_str(), (unsigned long)(millis() - td));
return false;
}
}
if(millis() - td > 500)
Serial.printf("[dns] %s %lums\n", host.c_str(), (unsigned long)(millis() - td));
}
}
cli.setInsecure();
// CoinGecko sits behind Cloudflare, OWM answers with a single LE cert - 3k RX
// is plenty (MFLN) and keeps the handshake around ~15k. Session resumption
// (per host) makes every reconnect after the first one far cheaper - without
// it each fetch re-parsed the whole cert chain. The dead binance branch was
// removed here: no code ever builds such a URL, but its Session object still
// cost ~150B of DRAM the TLS stack needs.
static BearSSL::Session sessCG, sessOWM;
BearSSL::Session* sess = &sessOWM;
if(url.indexOf("api.coingecko.com") >= 0) sess = &sessCG;
cli.setSession(sess);
// MFLN keeps records small; 2048 RX still holds the fragmented Cloudflare
// cert but trims the handshake peak by ~1k. That headroom is what keeps the
// WiFi task from OOM-crashing in ieee80211_setup_ratetable when the heap is
// at its floor during a fetch.
cli.setBufferSizes(2048, 512);
http.setTimeout(6000); // keep loop() bounded: ESP8266 soft WDT = 8s
// (also covers DNS resolve + TCP connect)
http.setReuse(false);
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
// A handshake that dies with the engine allocated (OOM-class fail, free
// < 4k / ssl -1000) drops mfb below the 12k fetch gate - observed 8664 -
// and the gate then blocks every further attempt, so the boot ran stale
// ~10 minutes until the breaker rebooted. end() frees the whole engine;
// a retry in place starts from the reshuffled heap and usually connects
// (observed: fail 15063ms -> in-place retry 986ms).
// Measured on device: the cli+DNS prelude costs ~5.6-7.4k from the biggest
// block, so mid-run this sees ~9.1-9.5k free / 6.9-9.2k mfb; a GET succeeded
// from free=10.4k/mfb=8.2k while engine+iobufs+TCP ate 7.7-9.8k out of the
// biggest block (single-block eat observed: 8832 -> 1704 = 7128). Below
// ~7.4k starting mfb a BearSSL new fails mid-chain - with exceptions off
// that is an abort-reboot loop (observed: post-engine remainder < 216B).
// BUT the gate must not outrun the healthy baseline: a wedge state sat at
// mfb 7664-7856 with free ~10.3k for minutes and 8100 skipped every attempt
// until the breaker rebooted (the "mystery reboots"). 7550 passes all
// observed stuck values while keeping +206B over the abort line at the
// floor (transient ~7.0k post-teardown states still skip, now retrying in
// 2.5s instead of 6s so they catch the next mfb rebound).
{
uint32_t f0 = ESP.getFreeHeap(), m0 = ESP.getMaxFreeBlockSize();
if(f0 < 9000UL || m0 < 7550UL){
httpSkip = true;
mkErr = "heap low";
coolUntil = millis() + 2500UL;
lastTlsTry = 0; // not a real attempt: let the breaker fire
Serial.printf("[http] skip free=%u mfb=%u\n", (unsigned)f0,
(unsigned)m0);
return false;
}
Serial.printf("[http] try free=%u mfb=%u\n", (unsigned)f0,
(unsigned)m0);
}
uint8_t attempt = 0;
for(;;){
if(!http.begin(cli, url)){ mkErr = "begin failed"; return false; }
http.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
http.addHeader("Accept", "application/json");
if(cfg.cgKey.length() && url.indexOf("api.coingecko.com") >= 0)
http.addHeader("x-cg-demo-api-key", cfg.cgKey);
http.addHeader("Accept-Encoding", "identity");
code = http.GET();
lastHttpMs = millis() - t0;
ESP.wdtFeed();
if(code >= 0){ oomN = 0; break; } // the handshake fit: reset the OOM ladder
char eb[64] = {0};
int se = cli.getLastSSLError(eb, sizeof(eb));
String full = HTTPClient::errorToString(code) + " (" + String(code) + ") ssl=" + String(se);
if(eb[0]) full += " " + String(eb);
full += " h=" + String(ESP.getFreeHeap()) + "/" + String(ESP.getMaxFreeBlockSize());
Serial.printf("[http] connect fail -> %s\n", full.c_str());
bool oomish = (se == -1000 || ESP.getFreeHeap() < 4000);
http.end();
// HTTPClient::end() skips _client->stop() when the socket never reached
// connected() state, so the TLS engine would stay allocated (~14k) and
// the retry started from 2k free -> BearSSL's new() threw "Unhandled C++
// exception: OOM" and the device reboot-looped. stop() -> _freeSSL()
// releases the engine even for a half-open connection.
cli.stop();
if(oomish){
uint32_t fA = ESP.getFreeHeap(), mA = ESP.getMaxFreeBlockSize();
Serial.printf("[http] post-stop free=%u mfb=%u\n", (unsigned)fA, (unsigned)mA);
// retry only when BOTH metrics say a fresh handshake fits: total free
// alone let a retry start against a shredded heap (engine is one big
// block -> new() -> OOM abort)
if(++attempt < 3 && fA > 12000 && mA > 12000){
Serial.printf("[http] retry %d\n", (int)attempt);
delay(200);
continue;
}
// heap race with the webserver/browser: short message + escalating
// retry - 5s, 5s, then every 20s. A flat 45s backoff would leave the
// price blank far too long after picking a coin, while an endless 5s
// loop would keep the error on the display as long as the browser holds
// the heap (it clears on the first success). The free-heap check also
// catches TCP "connection failed" when lwip can't even allocate the
// socket next to the TLS buffers - same root cause as the BearSSL OOM.
oomN++;
// se==0 + free<4000 is the observed OUTER network-wave shape (three
// "connection failed" in a row while a PC-curl works) - the engine and
// the prelude hold the heap down there, there is no allocator failure.
// Saying "no memory" put a scary false error on the weather panel;
// keep the oomish fast-retry ladder but tell the user the truth.
mkErr = (se == -1000) ? "no memory (oom)" : "connection failed";
coolUntil = millis() + (oomN >= 3 ? 60000UL : 2500UL);
return false;
}
// non-heap connect fail: full text stays in the serial log, the panel
// gets a localizable short message (long "code ssl h=.." never matched)
mkErr = (code == HTTPC_ERROR_CONNECTION_FAILED) ? "connection failed" : full;
coolUntil = millis() + (code == HTTPC_ERROR_CONNECTION_FAILED ? 15000UL : 45000UL);
return false;
}
if(code == 429){
http.end();
mkErr = "rate limited (429)";
coolUntil = millis() + 120000UL; // CoinGecko free tier: back off 2 minutes
Serial.println("[http] 429 -> backoff 120s");
return false;
}
return true;
}
// HTTP/1.1 chunked framing decoded on the fly, so a reply can be parsed
// straight from the socket. Measured after a handshake: the engine ate the
// big block and the free heap is a single ~300-550B island - the old buffered
// path's reserve ladder (1536+) never fit there, so every chunked reply
// (CoinGecko is always chunked) failed with "lowheap (oom)" and the price
// froze. A chunk header is a few bytes and the parsers walk any Stream, so
// the body never needs a contiguous buffer at all.
class ChunkedStream : public Stream {
public:
void attach(WiFiClient* c){ _c = c; _state = 0; _remain = 0; _done = (c == nullptr); }
size_t count() const { return _bytes; }
int available() override{
if(_pbSet) return 1;
if(!_c || _done) return 0;
if(_remain > 0) return _c->available();
return 1; // a size line / terminator may be pending
}
int read() override{
if(_pbSet){ _pbSet = false; return _pb; }
if(!_c || _done) return -1;
for(;;){
if(_remain > 0){
int b = waitByte();
if(b < 0) return -1;
_bytes++; _remain--;
if(_remain == 0) _state = 2; // swallow the trailing CRLF next
return b;
}
if(_state == 2){
if(waitByte() != '\r' || waitByte() != '\n'){ _done = true; return -1; }
_state = 0;
continue;
}
// chunk-size line: hex digits [; extension] CRLF
char line[24];
bool nl = false;
int n = 0;
for(; n < (int)sizeof(line) - 1; n++){
int b = waitByte();
if(b < 0) break;
line[n] = (char)b;
if(b == '\n'){ nl = true; break; }
}
if(!nl){ _done = true; return -1; }
line[n + 1] = 0;
char* sc = strchr(line, ';');
if(sc) *sc = 0;
_remain = strtoul(line, nullptr, 16);
if(_remain == 0){ _done = true; return -1; } // terminating chunk
_state = 1;
}
}
int peek() override{
if(_pbSet) return _pb;
int b = read();
if(b >= 0){ _pb = b; _pbSet = true; }
return b;
}
void flush() override {}
size_t write(uint8_t) override { return 0; }
size_t write(const uint8_t*, size_t n) override { return n; }
using Print::write;
private:
// blocks out inter-packet gaps like Stream::timedRead does, but only when
// the socket really has nothing (read() itself is non-blocking)
int waitByte(){
uint32_t start = millis();
for(;;){
int b = _c->read();
if(b >= 0) return b;
if(!_c->connected() && _c->available() == 0) return -1;
if(millis() - start >= _timeout) return -1;
delay(1);
}
}
WiFiClient* _c = nullptr;
uint32_t _remain = 0;
uint8_t _state = 0; // 0 = size line, 1 = body, 2 = eat CRLF
bool _done = true;
bool _pbSet = false;
int _pb = -1;
size_t _bytes = 0;
};
static bool httpJsonOnce(const String& url, JsonDocument& doc, const char* filter = nullptr){
uint32_t heapBefore = ESP.getFreeHeap();
BearSSL::WiFiClientSecure cli;
HTTPClient http;
int code = 0;
if(!httpGetStart(cli, http, url, code)) return false;
if(code != 200){
char eb[64] = {0};
int se = cli.getLastSSLError(eb, sizeof(eb));
mkErr = HTTPClient::errorToString(code);
mkErr += " (" + String(code) + ") ssl=" + String(se);
if(eb[0]) mkErr += " " + String(eb);
http.end();
return false;
}
Serial.printf("[http] %d heap %u->%u mfb=%u\n", code, heapBefore,
ESP.getFreeHeap(), ESP.getMaxFreeBlockSize());
// Small bodies (OWM current weather ~2.5k, price, quotes, holidays): buffer
// the payload FIRST and only then tear the TLS session down, so the document
// is parsed against the full heap instead of beside the ~10.5k TLS footprint
// (stream-parsing there failed with NoMemory on a fragmented heap).
int clen = http.getSize();
if(clen > 0 && clen <= 3072){
StreamString body;
if(!body.reserve((size_t)clen + 1)){
Serial.printf("[json] body reserve fail mfb=%u -> stream\n",
(unsigned)ESP.getMaxFreeBlockSize());
} else {
int wr = http.writeToStream(&body);
http.end();
// http.end() can leave the TLS engine alive when disconnect() sees the
// socket already closed; the engine then lives until this function
// returns and the parse below runs beside it - measured: OWM wx died
// with "buffered fail NoMemory heap=2736" (engine-held) while hb showed
// 16.6k two seconds later (dtors). Force the teardown so the document
// is parsed against the full heap, as the comment above promises.
cli.stop();
ESP.wdtFeed();
if(wr < 0){
mkErr = HTTPClient::errorToString(wr) + " (" + String(wr) + ")";
return false;
}
if(body.length() == 0){ mkErr = "empty body"; return false; }
DeserializationError e;
if(filter){
JsonDocument fd;
if(deserializeJson(fd, filter) == DeserializationError::Ok)
e = deserializeJson(doc, body.c_str(), body.length(),
DeserializationOption::Filter(fd));
else
e = deserializeJson(doc, body.c_str(), body.length());
} else {
e = deserializeJson(doc, body.c_str(), body.length());
}
if(e){
mkErr = String("json ") + e.c_str() + " @" + urlPathTail(url);
Serial.printf("[json] buffered fail %s heap=%u\n", e.c_str(), ESP.getFreeHeap());
return false;
}
mkErr = "";
return true;
}
}
// Large bodies (CoinGecko OHLC, OWM forecast): parse straight from the socket
// so the payload never sits in RAM next to the parsed document - on an 8266
// with ~18k free heap the copy path alone caused NoMemory / Stream write
// errors. Known content length (identity encoding); when the reserve above
// did not fit, this is the fallback for small content-length replies too.
if(http.getSize() > 0){
JsonDocument fd;
DeserializationError e;
if(filter && deserializeJson(fd, filter) == DeserializationError::Ok)
e = deserializeJson(doc, http.getStream(), DeserializationOption::Filter(fd));
else
e = deserializeJson(doc, http.getStream());
char eb[64] = {0};
int se = cli.getLastSSLError(eb, sizeof(eb));
http.end();
ESP.wdtFeed();
if(e){
mkErr = String("json ") + e.c_str() + " @" + urlPathTail(url);
if(eb[0]) mkErr += " ssl=" + String(se);
Serial.printf("[json] stream fail %s heap=%u\n", e.c_str(), ESP.getFreeHeap());
return false;
}
mkErr = "";
return true;
}
// chunked (no content length): decode the framing on the fly and parse
// straight from the socket. Measured beside the open TLS session: the
// engine holds the big block and the free heap is a single ~300-550B
// island, so every body buffer - even 1536B - failed to reserve and each
// CoinGecko reply (they are all chunked) died as "lowheap (oom)" while the
// handshake itself was fine. A Stream parse needs no body memory at all.
{
ChunkedStream cs;
cs.attach(http.getStreamPtr());
cs.setTimeout(2500);
DeserializationError e;
{
JsonDocument fd;
bool haveF = filter &&
(deserializeJson(fd, filter) == DeserializationError::Ok);
if(haveF)
e = deserializeJson(doc, cs, DeserializationOption::Filter(fd));
else
e = deserializeJson(doc, cs);
}
size_t got = cs.count();
http.end();
ESP.wdtFeed();
if(e){
mkErr = String("json ") + e.c_str() + " @" + urlPathTail(url);