-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathweb_server.cpp
More file actions
1926 lines (1725 loc) · 69.1 KB
/
Copy pathweb_server.cpp
File metadata and controls
1926 lines (1725 loc) · 69.1 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 "web_server.h"
#include "config.h"
#include "espnow_comm.h"
#include "finish_gate.h"
#include "start_gate.h"
#include "wled_integration.h"
#include "audio_manager.h"
#include "lidar_sensor.h"
#include "html_index.h"
#include "html_config.h"
#include "html_console.h"
#include "html_start_status.h"
#include "html_chartjs.h"
#include "html_speedtrap_status.h"
#include <ArduinoJson.h>
#include <LittleFS.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <HTTPUpdate.h>
#include <NetworkClientSecure.h>
#include <Update.h>
#include <esp_mac.h>
#include <Wire.h>
WebServer server(80);
WebSocketsServer webSocket(81);
SerialTee serialTee;
// ============================================================================
// FIRMWARE UPDATE — Root CA certs for GitHub TLS verification
// ============================================================================
// Two root CAs covering GitHub's TLS chains (extracted Feb 2026):
// 1. Sectigo Public Server Authentication Root E46 (cross-signed by USERTrust ECC)
// Covers: api.github.com, github.com — Expires: Jan 18 2038
// 2. USERTrust RSA Certification Authority (cross-signed by AAA Certificate Services)
// Covers: objects.githubusercontent.com — Expires: Dec 31 2028
// If these expire or GitHub rotates CAs, the firmware falls back to setInsecure()
// and logs a warning. The next firmware update then delivers fresh certs.
static const char github_root_ca_pem[] PROGMEM = R"CERT(
-----BEGIN CERTIFICATE-----
MIIDRjCCAsugAwIBAgIQGp6v7G3o4ZtcGTFBto2Q3TAKBggqhkjOPQQDAzCBiDEL
MAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNl
eSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMT
JVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMjEwMzIy
MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjBfMQswCQYDVQQGEwJHQjEYMBYGA1UEChMP
U2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIg
QXV0aGVudGljYXRpb24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAR2
+pmpbiDt+dd34wc7qNs9Xzjoq1WmVk/WSOrsfy2qw7LFeeyZYX8QeccCWvkEN/U0
NSt3zn8gj1KjAIns1aeibVvjS5KToID1AZTc8GgHHs3u/iVStSBDHBv+6xnOQ6Oj
ggEgMIIBHDAfBgNVHSMEGDAWgBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAdBgNVHQ4E
FgQU0SLaTFnxS18mOKqd1u7rDcP7qWEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB
/wQFMAMBAf8wHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMBEGA1UdIAQK
MAgwBgYEVR0gADBQBgNVHR8ESTBHMEWgQ6BBhj9odHRwOi8vY3JsLnVzZXJ0cnVz
dC5jb20vVVNFUlRydXN0RUNDQ2VydGlmaWNhdGlvbkF1dGhvcml0eS5jcmwwNQYI
KwYBBQUHAQEEKTAnMCUGCCsGAQUFBzABhhlodHRwOi8vb2NzcC51c2VydHJ1c3Qu
Y29tMAoGCCqGSM49BAMDA2kAMGYCMQCMCyBit99vX2ba6xEkDe+YO7vC0twjbkv9
PKpqGGuZ61JZryjFsp+DFpEclCVy4noCMQCwvZDXD/m2Ko1HA5Bkmz7YQOFAiNDD
49IWa2wdT7R3DtODaSXH/BiXv8fwB9su4tU=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFgTCCBGmgAwIBAgIQOXJEOvkit1HX02wQ3TE1lTANBgkqhkiG9w0BAQwFADB7
MQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD
VQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE
AwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTE5MDMxMjAwMDAwMFoXDTI4
MTIzMTIzNTk1OVowgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5
MRQwEgYDVQQHEwtKZXJzZXkgQ2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBO
ZXR3b3JrMS4wLAYDVQQDEyVVU0VSVHJ1c3QgUlNBIENlcnRpZmljYXRpb24gQXV0
aG9yaXR5MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAgBJlFzYOw9sI
s9CsVw127c0n00ytUINh4qogTQktZAnczomfzD2p7PbPwdzx07HWezcoEStH2jnG
vDoZtF+mvX2do2NCtnbyqTsrkfjib9DsFiCQCT7i6HTJGLSR1GJk23+jBvGIGGqQ
Ijy8/hPwhxR79uQfjtTkUcYRZ0YIUcuGFFQ/vDP+fmyc/xadGL1RjjWmp2bIcmfb
IWax1Jt4A8BQOujM8Ny8nkz+rwWWNR9XWrf/zvk9tyy29lTdyOcSOk2uTIq3XJq0
tyA9yn8iNK5+O2hmAUTnAU5GU5szYPeUvlM3kHND8zLDU+/bqv50TmnHa4xgk97E
xwzf4TKuzJM7UXiVZ4vuPVb+DNBpDxsP8yUmazNt925H+nND5X4OpWaxKXwyhGNV
icQNwZNUMBkTrNN9N6frXTpsNVzbQdcS2qlJC9/YgIoJk2KOtWbPJYjNhLixP6Q5
D9kCnusSTJV882sFqV4Wg8y4Z+LoE53MW4LTTLPtW//e5XOsIzstAL81VXQJSdhJ
WBp/kjbmUZIO8yZ9HE0XvMnsQybQv0FfQKlERPSZ51eHnlAfV1SoPv10Yy+xUGUJ
5lhCLkMaTLTwJUdZ+gQek9QmRkpQgbLevni3/GcV4clXhB4PY9bpYrrWX1Uu6lzG
KAgEJTm4Diup8kyXHAc/DVL17e8vgg8CAwEAAaOB8jCB7zAfBgNVHSMEGDAWgBSg
EQojPpbxB+zirynvgqV/0DCktDAdBgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rID
ZsswDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wEQYDVR0gBAowCDAG
BgRVHSAAMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29t
L0FBQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggr
BgEFBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBDAUA
A4IBAQAYh1HcdCE9nIrgJ7cz0C7M7PDmy14R3iJvm3WOnnL+5Nb+qh+cli3vA0p+
rvSNb3I8QzvAP+u431yqqcau8vzY7qN7Q/aGNnwU4M309z/+3ri0ivCRlv79Q2R+
/czSAaF9ffgZGclCKxO/WIu6pKJmBHaIkU4MiRTOok3JMrO66BQavHHxW/BBC5gA
CiIDEOUMsfnNkjcZ7Tvx5Dq2+UUTJnWvu6rvP3t3O9LEApE9GQDTF1w52z97GA1F
zZOFli9d31kWTz9RvdVFGD/tSo7oBmF0Ixa1DVBzJ0RHfxBdiSprhTEUxOipakyA
vGp4z7h/jnZymQyd/teRCBaho1+V
-----END CERTIFICATE-----
)CERT";
// Firmware update scheduling state (set by HTTP handler, consumed by loop)
static volatile bool firmwareUpdateScheduled = false;
static volatile bool firmwareUpdateInProgress = false;
static char firmwareUpdateUrl[384] = "";
static char firmwareExpectedMd5[33] = ""; // 32 hex chars + null
static char firmwareUpdateStatus[128] = ""; // Human-readable status message
// ============================================================================
// FILE SERVING HELPERS
// ============================================================================
static String getContentType(const String& path) {
if (path.endsWith(".html")) return "text/html";
if (path.endsWith(".css")) return "text/css";
if (path.endsWith(".js")) return "application/javascript";
if (path.endsWith(".json")) return "application/json";
if (path.endsWith(".csv")) return "text/csv";
return "text/plain";
}
// ============================================================================
// API AUTHENTICATION
// Simple API key check for destructive endpoints.
// Reuses OTA password as the key. Returns true if authorized.
// ============================================================================
static bool requireAuth() {
if (strlen(cfg.ota_password) == 0) return true; // No password set = open
String key = server.header("X-API-Key");
if (key == cfg.ota_password) return true;
server.send(401, "application/json", "{\"error\":\"Unauthorized. Provide X-API-Key header.\"}");
return false;
}
static void serveFile(const String& path, const String& contentType) {
if (LittleFS.exists(path)) {
File file = LittleFS.open(path, "r");
server.streamFile(file, contentType);
file.close();
} else {
server.send(404, "text/plain", "File not found: " + path);
}
}
// ============================================================================
// WEBSOCKET HANDLER
// ============================================================================
static void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) {
switch (type) {
case WStype_CONNECTED:
broadcastState();
break;
case WStype_TEXT: {
StaticJsonDocument<512> doc; // 512 to fit Google Sheets URLs
DeserializationError error = deserializeJson(doc, payload);
if (error) return;
const char* cmd = doc["cmd"];
if (!cmd) return;
if (strcmp(cmd, "arm") == 0) {
raceState = ARMED;
portENTER_CRITICAL(&finishTimerMux);
startTime_us = 0;
finishTime_us = 0;
portEXIT_CRITICAL(&finishTimerMux);
// Aggressive clock sync before race — guarantees sub-50µs accuracy
sendToPeer(MSG_SYNC_REQ, nowUs(), 0);
// Tell start gate to arm too
sendToPeer(MSG_ARM_CMD, nowUs(), 0);
setWLEDState("armed");
broadcastState();
}
else if (strcmp(cmd, "reset") == 0) {
raceState = IDLE;
portENTER_CRITICAL(&finishTimerMux);
startTime_us = 0;
finishTime_us = 0;
portEXIT_CRITICAL(&finishTimerMux);
// Tell start gate to disarm
sendToPeer(MSG_DISARM_CMD, nowUs(), 0);
setWLEDState("idle");
broadcastState();
}
else if (strcmp(cmd, "setCar") == 0) {
currentCar = doc["name"].as<String>();
currentWeight = doc["weight"];
}
else if (strcmp(cmd, "setTrack") == 0) {
cfg.track_length_m = doc["length"];
}
else if (strcmp(cmd, "syncClock") == 0) {
sendToPeer(MSG_SYNC_REQ, nowUs(), 0);
}
else if (strcmp(cmd, "setDryRun") == 0) {
dryRunMode = doc["enabled"] | false;
LOG.printf("[WEB] Dry-run mode %s\n", dryRunMode ? "ENABLED" : "DISABLED");
broadcastState();
}
else if (strcmp(cmd, "setSheetsUrl") == 0) {
// Update Google Sheets URL in config and save (no reboot needed)
const char* url = doc["url"];
if (url) {
strncpy(cfg.google_sheets_url, url, sizeof(cfg.google_sheets_url) - 1);
cfg.google_sheets_url[sizeof(cfg.google_sheets_url) - 1] = '\0';
saveConfig();
LOG.printf("[WEB] Google Sheets URL updated: %s\n", cfg.google_sheets_url);
}
}
break;
}
default:
break;
}
}
// ============================================================================
// BROADCAST STATE
// ============================================================================
void broadcastState() {
StaticJsonDocument<1024> doc;
const char* stateStr;
switch (raceState) {
case IDLE: stateStr = "IDLE"; break;
case ARMED: stateStr = "ARMED"; break;
case RACING: stateStr = "RACING"; break;
case FINISHED: stateStr = "FINISHED"; break;
default: stateStr = "UNKNOWN"; break;
}
doc["state"] = stateStr;
doc["connected"] = peerConnected;
doc["car"] = currentCar;
doc["weight"] = currentWeight;
doc["trackLength"] = cfg.track_length_m;
doc["scaleFactor"] = cfg.scale_factor;
doc["totalRuns"] = totalRuns;
doc["role"] = cfg.role;
doc["units"] = cfg.units;
doc["google_sheets_url"] = cfg.google_sheets_url;
doc["dryRun"] = dryRunMode;
// Speed trap mid-track velocity (if available)
if (midTrackSpeed_mps > 0) {
doc["midTrack_mps"] = midTrackSpeed_mps;
doc["midTrack_mph"] = midTrackSpeed_mps * MPS_TO_MPH;
doc["midTrack_scale_mph"] = midTrackSpeed_mps * MPS_TO_MPH * (double)cfg.scale_factor;
}
// LiDAR sensor data (if enabled)
if (cfg.lidar_enabled) {
JsonObject lidar = doc.createNestedObject("lidar");
LidarState ls = getLidarState();
lidar["state"] = (ls == LIDAR_NO_CAR) ? "empty" :
(ls == LIDAR_CAR_STAGED) ? "staged" : "launched";
lidar["distance_mm"] = getDistanceMM();
}
// Proximity arm sensor data (HW-870 on start gate)
if (isProxArmEnabled()) {
doc["proxArm"] = true;
doc["proxCar"] = isProxCarPresent();
}
// Peer count for dashboard status indicators
int onlinePeers = 0;
for (int i = 0; i < peerCount; i++) {
if (peers[i].paired && getPeerStatus(peers[i]) == PEER_ONLINE) onlinePeers++;
}
doc["peerCount"] = peerCount;
doc["onlinePeers"] = onlinePeers;
// Atomic snapshot of 64-bit timing vars (shared with ISR and ESP-NOW task)
uint64_t bcastFinish, bcastStart;
portENTER_CRITICAL(&finishTimerMux);
bcastFinish = finishTime_us;
bcastStart = startTime_us;
portEXIT_CRITICAL(&finishTimerMux);
if (raceState == FINISHED && bcastStart > 0 && bcastFinish > 0) {
// Use SIGNED math to detect underflows instead of wrapping to huge values
int64_t elapsed_us = (int64_t)bcastFinish - (int64_t)bcastStart;
// Sanity check: must be positive and < 60 seconds
if (elapsed_us > 0 && elapsed_us < MAX_RACE_DURATION_US) {
double elapsed_s = elapsed_us / 1000000.0;
double speed_ms = cfg.track_length_m / elapsed_s;
doc["time"] = elapsed_s;
doc["speed_mps"] = speed_ms;
doc["speed_mph"] = speed_ms * MPS_TO_MPH;
doc["scale_mph"] = speed_ms * MPS_TO_MPH * (double)cfg.scale_factor;
double mass_kg = currentWeight / 1000.0;
doc["momentum"] = mass_kg * speed_ms;
doc["ke"] = 0.5 * mass_kg * speed_ms * speed_ms;
} else {
// Timing error - report zeros instead of garbage
doc["time"] = 0;
doc["speed_mph"] = 0;
doc["scale_mph"] = 0;
doc["momentum"] = 0;
doc["ke"] = 0;
doc["timing_error"] = true;
}
}
String output;
serializeJson(doc, output);
webSocket.broadcastTXT(output);
}
// ============================================================================
// CONFIG API HANDLERS
// ============================================================================
static void handleApiConfig() {
if (server.method() == HTTP_GET) {
server.send(200, "application/json", configToJson());
}
else if (server.method() == HTTP_POST) {
if (!requireAuth()) return;
String body = server.arg("plain");
if (body.length() == 0) {
server.send(400, "application/json", "{\"error\":\"Empty body\"}");
return;
}
DeviceConfig tempCfg;
setDefaults(tempCfg);
// Try parsing the JSON
String testJson = body;
if (!configFromJson(testJson)) {
// configFromJson modifies global cfg, so restore defaults if parse fails
loadConfig();
server.send(400, "application/json", "{\"error\":\"Invalid config JSON\"}");
return;
}
// configFromJson already loaded into global cfg
cfg.configured = true;
// Auto-generate role-based hostname if user left it blank or default
if (strlen(cfg.hostname) == 0 || strcmp(cfg.hostname, "masstrap") == 0) {
char suffix[5];
getMacSuffix(suffix, sizeof(suffix));
generateHostname(cfg.role, suffix, cfg.hostname, sizeof(cfg.hostname));
LOG.printf("[CONFIG] Auto-generated hostname: %s\n", cfg.hostname);
}
if (!validateConfig(cfg)) {
loadConfig(); // Restore previous
server.send(400, "application/json", "{\"error\":\"Config validation failed\"}");
return;
}
if (!saveConfig()) {
server.send(500, "application/json", "{\"error\":\"Failed to save config\"}");
return;
}
// Include hostname in response so client knows where to redirect
String resp = "{\"status\":\"ok\",\"message\":\"Config saved. Rebooting...\",\"hostname\":\"";
resp += cfg.hostname;
resp += "\"}";
server.send(200, "application/json", resp);
server.client().flush(); // Force TCP send buffer drain
delay(1000);
WiFi.softAPdisconnect(true); // Kick AP clients so CNA sheet closes
delay(500);
ESP.restart();
}
}
static void handleApiScan() {
// Use passive scan in AP mode to avoid disrupting the radio state
// Active scans can cause "association refused" on next WiFi.begin()
wifi_scan_config_t scanConfig = {};
scanConfig.scan_type = WIFI_SCAN_TYPE_PASSIVE;
scanConfig.scan_time.passive = 300; // 300ms per channel
int n = WiFi.scanNetworks(false, false, false, scanConfig.scan_time.passive);
StaticJsonDocument<1024> doc;
JsonArray arr = doc.to<JsonArray>();
for (int i = 0; i < n && i < 20; i++) {
JsonObject net = arr.createNestedObject();
net["ssid"] = WiFi.SSID(i);
net["rssi"] = WiFi.RSSI(i);
net["secure"] = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
}
WiFi.scanDelete();
String output;
serializeJson(doc, output);
server.send(200, "application/json", output);
}
static void handleApiMac() {
StaticJsonDocument<128> doc;
// WiFi.macAddress() returns STA MAC which may be 00:00:00:00:00:00 in AP-only mode
// Use esp_efuse_mac_get_default() to always get the base MAC burned into the chip
uint8_t baseMac[6];
esp_efuse_mac_get_default(baseMac);
char macStr[18];
snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X",
baseMac[0], baseMac[1], baseMac[2], baseMac[3], baseMac[4], baseMac[5]);
doc["mac"] = macStr;
String output;
serializeJson(doc, output);
server.send(200, "application/json", output);
}
static void handleApiBackup() {
String json = configToJson();
server.sendHeader("Content-Disposition", "attachment; filename=masstrap-config.json");
server.send(200, "application/json", json);
}
// ============================================================================
// SYSTEM SNAPSHOT API - Full backup/restore of config + garage + history
// ============================================================================
static void handleApiSystemBackup() {
// Read all three data files from LittleFS
String configJson = configToJson();
String garageJson = "[]";
if (LittleFS.exists("/garage.json")) {
File f = LittleFS.open("/garage.json", "r");
garageJson = f.readString();
f.close();
}
String historyJson = "[]";
if (LittleFS.exists("/history.json")) {
File f = LittleFS.open("/history.json", "r");
historyJson = f.readString();
f.close();
}
// Build the unified snapshot envelope
DynamicJsonDocument doc(16384);
doc["snapshot_version"] = 1;
doc["firmware_version"] = FIRMWARE_VERSION;
doc["hostname"] = cfg.hostname;
doc["role"] = cfg.role;
// Parse config into nested object
DynamicJsonDocument configDoc(1536);
deserializeJson(configDoc, configJson);
doc["config"] = configDoc.as<JsonObject>();
// Parse garage into nested array
DynamicJsonDocument garageDoc(4096);
deserializeJson(garageDoc, garageJson);
doc["garage"] = garageDoc.as<JsonArray>();
// Parse history into nested array
DynamicJsonDocument historyDoc(8192);
deserializeJson(historyDoc, historyJson);
doc["history"] = historyDoc.as<JsonArray>();
String output;
serializeJsonPretty(doc, output);
server.sendHeader("Content-Disposition", "attachment; filename=masstrap-system-backup.json");
server.send(200, "application/json", output);
LOG.printf("[WEB] System snapshot exported (%d bytes)\n", output.length());
}
static void handleApiSystemRestore() {
if (!requireAuth()) return;
String body = server.arg("plain");
if (body.length() == 0) {
server.send(400, "application/json", "{\"error\":\"Empty body\"}");
return;
}
DynamicJsonDocument doc(16384);
DeserializationError err = deserializeJson(doc, body);
if (err) {
server.send(400, "application/json", "{\"error\":\"Invalid JSON\"}");
return;
}
if (!doc.containsKey("snapshot_version") || !doc.containsKey("config")) {
server.send(400, "application/json", "{\"error\":\"Not a valid system snapshot\"}");
return;
}
bool skipNetwork = server.hasArg("skip_network") && server.arg("skip_network") == "true";
// 1. Restore config
JsonObject configObj = doc["config"];
if (!configObj.isNull()) {
if (skipNetwork) {
// Clone mode: keep this device's network identity
configObj["network"]["wifi_ssid"] = cfg.wifi_ssid;
configObj["network"]["wifi_pass"] = cfg.wifi_pass;
configObj["network"]["hostname"] = cfg.hostname;
}
String configStr;
serializeJson(configObj, configStr);
File f = LittleFS.open(CONFIG_FILE, "w");
if (f) { f.print(configStr); f.close(); }
}
// 2. Restore garage
JsonArray garageArr = doc["garage"];
if (!garageArr.isNull()) {
String garageStr;
serializeJson(garageArr, garageStr);
File f = LittleFS.open("/garage.json", "w");
if (f) { f.print(garageStr); f.close(); }
}
// 3. Restore history
JsonArray historyArr = doc["history"];
if (!historyArr.isNull()) {
String historyStr;
serializeJson(historyArr, historyStr);
File f = LittleFS.open("/history.json", "w");
if (f) { f.print(historyStr); f.close(); }
}
LOG.printf("[WEB] System snapshot restored (skip_network=%s). Rebooting...\n",
skipNetwork ? "true" : "false");
server.send(200, "application/json",
"{\"status\":\"ok\",\"message\":\"System snapshot restored. Rebooting...\"}");
server.client().flush();
delay(1000);
WiFi.softAPdisconnect(true);
delay(500);
ESP.restart();
}
static void handleApiRestore() {
if (!requireAuth()) return;
String body = server.arg("plain");
if (body.length() == 0) {
server.send(400, "application/json", "{\"error\":\"Empty body\"}");
return;
}
// Validate JSON structure
StaticJsonDocument<1024> doc;
DeserializationError err = deserializeJson(doc, body);
if (err) {
server.send(400, "application/json", "{\"error\":\"Invalid JSON\"}");
return;
}
if (!doc.containsKey("version") || !doc.containsKey("configured")) {
server.send(400, "application/json", "{\"error\":\"Not a valid config file\"}");
return;
}
// Write directly to config file
File f = LittleFS.open(CONFIG_FILE, "w");
if (!f) {
server.send(500, "application/json", "{\"error\":\"Failed to write config\"}");
return;
}
f.print(body);
f.close();
server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"Config restored. Rebooting...\"}");
server.client().flush();
delay(1000);
WiFi.softAPdisconnect(true);
delay(500);
ESP.restart();
}
static void handleApiReset() {
if (!requireAuth()) return;
server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"Factory reset. Rebooting...\"}");
server.client().flush();
delay(1000);
WiFi.softAPdisconnect(true);
delay(500);
resetConfig();
}
static void handleApiInfo() {
StaticJsonDocument<512> doc;
doc["project"] = PROJECT_NAME;
doc["firmware"] = FIRMWARE_VERSION;
doc["role"] = cfg.role;
doc["hostname"] = cfg.hostname;
doc["uptime_s"] = millis() / 1000;
doc["free_heap"] = ESP.getFreeHeap();
doc["wifi_rssi"] = WiFi.RSSI();
doc["wifi_channel"] = WiFi.channel();
doc["peer_connected"] = peerConnected;
doc["peer_count"] = peerCount;
doc["ip"] = WiFi.localIP().toString();
doc["audio_enabled"] = cfg.audio_enabled;
doc["lidar_enabled"] = cfg.lidar_enabled;
doc["clock_offset_us"] = (double)clockOffset_us;
String output;
serializeJson(doc, output);
server.send(200, "application/json", output);
}
// WiFi diagnostic status — extern from MASS_Trap.ino
extern bool wifiConnected;
extern char wifiFailReason[64];
static void handleApiWifiStatus() {
StaticJsonDocument<256> doc;
doc["connected"] = (WiFi.status() == WL_CONNECTED);
doc["ssid"] = cfg.wifi_ssid;
doc["ip"] = WiFi.localIP().toString();
doc["rssi"] = WiFi.RSSI();
doc["channel"] = WiFi.channel();
doc["mode"] = (WiFi.getMode() == WIFI_AP) ? "AP" :
(WiFi.getMode() == WIFI_STA) ? "STA" :
(WiFi.getMode() == WIFI_AP_STA) ? "AP_STA" : "OFF";
if (strlen(wifiFailReason) > 0) {
doc["fail_reason"] = wifiFailReason;
}
String output;
serializeJson(doc, output);
server.send(200, "application/json", output);
}
static void handleApiVersion() {
StaticJsonDocument<256> doc;
doc["firmware"] = FIRMWARE_VERSION;
doc["web_ui"] = WEB_UI_VERSION;
doc["build_date"] = BUILD_DATE;
doc["build_time"] = BUILD_TIME;
#if CONFIG_IDF_TARGET_ESP32S3
doc["board"] = "ESP32-S3";
#elif CONFIG_IDF_TARGET_ESP32
doc["board"] = "ESP32";
#else
doc["board"] = "Unknown";
#endif
String output;
serializeJson(doc, output);
server.send(200, "application/json", output);
}
// ============================================================================
// HARDWARE DIAGNOSTICS — Remote CODE 3 Troubleshooting
// ============================================================================
// Comprehensive system health check for remote support. Reports pin states,
// I2C bus scan, memory, radio, filesystem, and peripheral status.
// Available in BOTH normal mode and setup mode — helps builders verify wiring
// before and after configuration.
static void handleApiDiagnostics() {
DynamicJsonDocument doc(4096);
// ---- SYSTEM INFO ----
JsonObject sys = doc.createNestedObject("system");
sys["firmware"] = FIRMWARE_VERSION;
sys["role"] = cfg.role;
sys["hostname"] = cfg.hostname;
sys["uptime_s"] = millis() / 1000;
sys["uptime_str"] = String(millis() / 3600000) + "h " +
String((millis() / 60000) % 60) + "m " +
String((millis() / 1000) % 60) + "s";
#if CONFIG_IDF_TARGET_ESP32S3
sys["board"] = "ESP32-S3";
#elif CONFIG_IDF_TARGET_ESP32
sys["board"] = "ESP32";
#else
sys["board"] = "Unknown";
#endif
sys["cpu_freq_mhz"] = ESP.getCpuFreqMHz();
sys["flash_size"] = ESP.getFlashChipSize();
sys["flash_speed"] = ESP.getFlashChipSpeed();
sys["sdk"] = ESP.getSdkVersion();
// ---- MEMORY ----
JsonObject mem = doc.createNestedObject("memory");
mem["free_heap"] = ESP.getFreeHeap();
mem["min_free_heap"] = ESP.getMinFreeHeap();
mem["max_alloc_heap"] = ESP.getMaxAllocHeap();
mem["total_heap"] = ESP.getHeapSize();
mem["heap_pct_free"] = (ESP.getHeapSize() > 0)
? (int)(100.0 * ESP.getFreeHeap() / ESP.getHeapSize()) : 0;
#ifdef BOARD_HAS_PSRAM
mem["psram_total"] = ESP.getPsramSize();
mem["psram_free"] = ESP.getFreePsram();
mem["psram_pct_free"] = (ESP.getPsramSize() > 0)
? (int)(100.0 * ESP.getFreePsram() / ESP.getPsramSize()) : 0;
#else
mem["psram_total"] = 0;
mem["psram_free"] = 0;
#endif
// ---- FILESYSTEM ----
JsonObject fs = doc.createNestedObject("filesystem");
fs["total_bytes"] = LittleFS.totalBytes();
fs["used_bytes"] = LittleFS.usedBytes();
fs["free_bytes"] = LittleFS.totalBytes() - LittleFS.usedBytes();
fs["pct_used"] = (LittleFS.totalBytes() > 0)
? (int)(100.0 * LittleFS.usedBytes() / LittleFS.totalBytes()) : 0;
// ---- WIFI ----
JsonObject wifi = doc.createNestedObject("wifi");
wifi["mode"] = (WiFi.getMode() == WIFI_AP) ? "AP" :
(WiFi.getMode() == WIFI_STA) ? "STA" :
(WiFi.getMode() == WIFI_AP_STA) ? "AP_STA" : "OFF";
wifi["sta_connected"] = (WiFi.status() == WL_CONNECTED);
wifi["sta_ip"] = WiFi.localIP().toString();
wifi["sta_ssid"] = cfg.wifi_ssid;
wifi["rssi"] = WiFi.RSSI();
wifi["signal_quality"] = constrain(2 * (WiFi.RSSI() + 100), 0, 100); // -100=0%, -50=100%
wifi["channel"] = WiFi.channel();
wifi["mac_sta"] = WiFi.macAddress();
wifi["ap_ip"] = WiFi.softAPIP().toString();
wifi["ap_clients"] = WiFi.softAPgetStationNum();
// ---- ESP-NOW / PEERS ----
JsonObject radio = doc.createNestedObject("espnow");
radio["peer_connected"] = peerConnected;
radio["peer_count"] = peerCount;
radio["clock_offset_us"] = (double)clockOffset_us; // Cast for JSON precision
JsonArray peerList = radio.createNestedArray("peers");
for (int i = 0; i < peerCount && i < MAX_PEERS; i++) {
JsonObject p = peerList.createNestedObject();
p["role"] = peers[i].role;
p["hostname"] = peers[i].hostname;
p["mac"] = formatMac(peers[i].mac);
p["paired"] = peers[i].paired;
unsigned long ago = millis() - peers[i].lastSeen;
p["last_seen_ms"] = ago;
p["status"] = (ago < PEER_ONLINE_THRESH_MS) ? "ONLINE" :
(ago < PEER_STALE_THRESH_MS) ? "STALE" : "OFFLINE";
}
// ---- RACE STATE ----
JsonObject race = doc.createNestedObject("race");
const char* stateNames[] = {"IDLE", "ARMED", "RACING", "FINISHED"};
race["state"] = stateNames[(int)raceState];
race["dry_run"] = dryRunMode;
race["total_runs"] = totalRuns;
race["current_car"] = currentCar;
race["current_weight"] = currentWeight;
// ---- PIN CONFIGURATION ----
JsonObject pins = doc.createNestedObject("pins");
// IR Sensor (primary)
JsonObject irPin = pins.createNestedObject("ir_sensor");
irPin["gpio"] = cfg.sensor_pin;
irPin["configured"] = (cfg.sensor_pin > 0);
if (cfg.sensor_pin > 0) {
pinMode(cfg.sensor_pin, INPUT);
irPin["state"] = digitalRead(cfg.sensor_pin) ? "HIGH" : "LOW";
irPin["expected_idle"] = "HIGH (beam unbroken)";
irPin["ok"] = (digitalRead(cfg.sensor_pin) == HIGH);
}
// IR Sensor 2 (speed trap)
if (cfg.sensor_pin_2 > 0) {
JsonObject ir2Pin = pins.createNestedObject("ir_sensor_2");
ir2Pin["gpio"] = cfg.sensor_pin_2;
pinMode(cfg.sensor_pin_2, INPUT);
ir2Pin["state"] = digitalRead(cfg.sensor_pin_2) ? "HIGH" : "LOW";
ir2Pin["expected_idle"] = "HIGH (beam unbroken)";
ir2Pin["ok"] = (digitalRead(cfg.sensor_pin_2) == HIGH);
}
// LED pin
JsonObject ledPin = pins.createNestedObject("led");
ledPin["gpio"] = cfg.led_pin;
ledPin["configured"] = (cfg.led_pin > 0);
// Audio pins
if (cfg.audio_enabled) {
JsonObject audio = pins.createNestedObject("audio");
audio["enabled"] = true;
audio["bclk_gpio"] = cfg.i2s_bclk_pin;
audio["lrc_gpio"] = cfg.i2s_lrc_pin;
audio["dout_gpio"] = cfg.i2s_dout_pin;
audio["volume"] = cfg.audio_volume;
audio["playing"] = isPlaying();
}
// LiDAR pins
if (cfg.lidar_enabled) {
JsonObject lidar = pins.createNestedObject("lidar");
lidar["enabled"] = true;
lidar["rx_gpio"] = cfg.lidar_rx_pin;
lidar["tx_gpio"] = cfg.lidar_tx_pin;
lidar["threshold_mm"] = cfg.lidar_threshold_mm;
lidar["distance_mm"] = getDistanceMM();
const char* lidarStates[] = {"NO_CAR", "CAR_STAGED", "CAR_LAUNCHED"};
lidar["state"] = lidarStates[(int)getLidarState()];
lidar["ok"] = (getDistanceMM() > 0); // 0 = no reading = possible wiring issue
}
// ---- I2C BUS SCAN ----
// Scans the default I2C bus (SDA/SCL from board defaults) for connected devices.
// This catches BNO055, OLED displays, BME280, or any other I2C peripheral.
JsonObject i2c = doc.createNestedObject("i2c");
Wire.begin(); // Initialize with default SDA/SCL for the board
JsonArray devices = i2c.createNestedArray("devices");
int deviceCount = 0;
for (uint8_t addr = 1; addr < 127; addr++) {
Wire.beginTransmission(addr);
uint8_t err = Wire.endTransmission();
if (err == 0) {
JsonObject dev = devices.createNestedObject();
char addrHex[8];
snprintf(addrHex, sizeof(addrHex), "0x%02X", addr);
dev["address"] = addrHex;
// Identify well-known addresses
const char* name = "Unknown";
if (addr == 0x28 || addr == 0x29) name = "BNO055 IMU";
else if (addr == 0x3C || addr == 0x3D) name = "SSD1306 OLED";
else if (addr == 0x76 || addr == 0x77) name = "BME280/BMP280";
else if (addr == 0x68 || addr == 0x69) name = "MPU6050/DS3231";
else if (addr == 0x48) name = "ADS1115 ADC";
else if (addr == 0x50) name = "AT24C EEPROM";
else if (addr == 0x27 || addr == 0x3F) name = "PCF8574 I/O Expander";
else if (addr == 0x5A) name = "MLX90614 IR Temp";
else if (addr == 0x20) name = "PCF8574A I/O Expander";
dev["device"] = name;
deviceCount++;
}
}
Wire.end(); // Release I2C bus
i2c["device_count"] = deviceCount;
// ---- WLED INTEGRATION ----
if (strlen(cfg.wled_host) > 0) {
JsonObject wled = doc.createNestedObject("wled");
wled["host"] = cfg.wled_host;
// Quick reachability check (50ms timeout — don't block long)
HTTPClient http;
http.begin("http://" + String(cfg.wled_host) + "/json/info");
http.setTimeout(500);
int code = http.GET();
wled["reachable"] = (code == 200);
wled["http_code"] = code;
http.end();
}
// ---- CONFIG SUMMARY ----
JsonObject config = doc.createNestedObject("config");
config["configured"] = cfg.configured;
config["version"] = cfg.version;
config["network_mode"] = cfg.network_mode;
config["track_length_m"] = cfg.track_length_m;
config["scale_factor"] = cfg.scale_factor;
config["units"] = cfg.units;
config["audio_enabled"] = cfg.audio_enabled;
config["lidar_enabled"] = cfg.lidar_enabled;
config["has_wled"] = (strlen(cfg.wled_host) > 0);
config["has_viewer_auth"] = (strlen(cfg.viewer_password) > 0);
// ---- VERDICT ----
// Quick pass/fail summary for the wiring wizard "Verify Connection" button
JsonObject verdict = doc.createNestedObject("verdict");
int issues = 0;
JsonArray problems = verdict.createNestedArray("issues");
// Check IR sensor
if (cfg.sensor_pin > 0) {
pinMode(cfg.sensor_pin, INPUT);
if (digitalRead(cfg.sensor_pin) == LOW) {
problems.add("IR sensor (GPIO " + String(cfg.sensor_pin) + ") reads LOW — beam blocked or disconnected");
issues++;
}
}
// Check memory health
if (ESP.getFreeHeap() < 50000) {
problems.add("Low heap memory: " + String(ESP.getFreeHeap()) + " bytes free");
issues++;
}
// Check filesystem
if (LittleFS.totalBytes() - LittleFS.usedBytes() < 100000) {
problems.add("Low filesystem space: " + String(LittleFS.totalBytes() - LittleFS.usedBytes()) + " bytes free");
issues++;
}
// Check WiFi signal
if (WiFi.status() == WL_CONNECTED && WiFi.RSSI() < -80) {
problems.add("Weak WiFi signal: " + String(WiFi.RSSI()) + " dBm");
issues++;
}
// Check LiDAR if enabled
if (cfg.lidar_enabled && getDistanceMM() == 0) {
problems.add("LiDAR enabled but no reading — check RX/TX wiring (GPIO " +
String(cfg.lidar_rx_pin) + "/" + String(cfg.lidar_tx_pin) + ")");
issues++;
}
verdict["issue_count"] = issues;
verdict["status"] = (issues == 0) ? "ALL CLEAR" : "ISSUES DETECTED";
String output;
serializeJson(doc, output);
server.send(200, "application/json", output);
}
// ============================================================================
// PEER DISCOVERY API — Brother's Six Protocol
// ============================================================================
static void handleApiPeers() {
server.send(200, "application/json", getPeersJson());
}
static void handleApiPeersForget() {
if (!requireAuth()) return;
String body = server.arg("plain");
if (body.length() > 0) {
// Forget a specific peer by MAC
StaticJsonDocument<128> doc;
deserializeJson(doc, body);
const char* macStr = doc["mac"] | "";
uint8_t mac[6];
if (parseMacString(macStr, mac)) {
forgetPeer(mac);
server.send(200, "application/json", "{\"status\":\"ok\",\"action\":\"forgot_one\"}");
} else {
server.send(400, "application/json", "{\"error\":\"Invalid MAC\"}");
}
} else {
// Forget all peers
forgetAllPeers();
server.send(200, "application/json", "{\"status\":\"ok\",\"action\":\"forgot_all\"}");
}
}
// ============================================================================
// FLEET MANAGEMENT API — WiFi sharing and remote commands
// ============================================================================
// POST /api/peers/share-wifi
// Body (optional): {"mac":"AA:BB:CC:DD:EE:FF"} — omit for broadcast to all
static void handleApiShareWifi() {
if (!requireAuth()) return;
// Only the finish gate should push WiFi creds (it's the hub)
if (strcmp(cfg.role, "finish") != 0) {
server.send(403, "application/json", "{\"error\":\"Only finish gate can share WiFi credentials\"}");
return;
}
String body = server.arg("plain");
if (body.length() > 0) {
StaticJsonDocument<128> doc;
DeserializationError err = deserializeJson(doc, body);
if (err) {
server.send(400, "application/json", "{\"error\":\"Invalid JSON\"}");
return;
}
const char* macStr = doc["mac"] | "";
if (strlen(macStr) > 0) {
uint8_t mac[6];
if (parseMacString(macStr, mac)) {
sendWiFiConfig(mac);
server.send(200, "application/json", "{\"ok\":true,\"sent\":1}");
} else {
server.send(400, "application/json", "{\"error\":\"Invalid MAC address\"}");
}
return;
}
}
// No MAC specified — broadcast to all paired peers
sendWiFiConfigAll();
int pairedCount = 0;
for (int i = 0; i < peerCount; i++) {
if (peers[i].paired) pairedCount++;
}
char resp[64];
snprintf(resp, sizeof(resp), "{\"ok\":true,\"sent\":%d}", pairedCount);
server.send(200, "application/json", resp);
}
// POST /api/peers/command
// Body: {"mac":"AA:BB:CC:DD:EE:FF","cmd":"reboot","param":0}
static void handleApiPeerCommand() {
if (!requireAuth()) return;
// Only the finish gate should send remote commands
if (strcmp(cfg.role, "finish") != 0) {
server.send(403, "application/json", "{\"error\":\"Only finish gate can send remote commands\"}");
return;
}
String body = server.arg("plain");
if (body.length() == 0) {
server.send(400, "application/json", "{\"error\":\"Empty body\"}");
return;
}