· 5 months ago · Apr 16, 2025, 04:30 PM
1#include <ESP8266WiFi.h>
2#include <ESP8266WebServer.h>
3#include <ESP8266HTTPClient.h>
4#include <ArduinoJson.h>
5
6// WiFi credentials
7const char* ssid = "YOUR_SSID_HERE";
8const char* password = "YOUR_PASSWORD_HERE";
9
10// Web server on port 80
11ESP8266WebServer server(80);
12
13// Coordinates (Lon,Lat)
14const float LAT = ######; // Replace "######" with your LAT
15const float LON = ######; // Replace "######" with your LON
16int RADIUS = 2; // Modifiable (in nautical miles)
17
18// Update intervals
19const unsigned long UPDATE_INTERVAL = 8000; // 8 seconds for API update
20unsigned long lastUpdate = 0;
21unsigned long lastDataTime = 0;
22float seenTime = 0;
23float initialSeenTime = 0;
24
25// Aircraft data storage
26String currentCallsign = "";
27String currentType = "";
28String currentHex = "N/A";
29float currentAlt = 0;
30float currentSpeed = 0;
31float currentDirection = 0;
32float currentDistance = 0;
33bool hasAircraftData = false;
34
35// Aircraft counter
36unsigned long lastResetTime = 0;
37int aircraftCount = 0;
38String seenCallsigns[50]; //Max Count is 50 due to RAM limitations on the ESP
39int seenCallsignsIndex = 0;
40const unsigned long DAY_LENGTH = 86400000; // 24 hours
41
42// LED configuration
43const int LED_PIN = 2; // Built-in blue LED on most ESP8266 boards (GPIO2, D4)
44unsigned long lastFadeUpdate = 0;
45const unsigned long FADE_INTERVAL = 20; // Update fade every 20ms for smooth effect
46int fadeValue = 0; // Current PWM value (0-1023)
47bool fadeDirection = true; // true = increasing, false = decreasing
48
49// HTML for the web interface (Home Assistant style)
50const char* htmlPage = R"rawliteral(
51<!DOCTYPE html>
52<html lang="en">
53<head>
54 <meta charset="UTF-8">
55 <meta name="viewport" content="width=device-width, initial-scale=1.0">
56 <title>Aircraft Tracker</title>
57 <style>
58 body {
59 background: #1c2526;
60 color: #e0e0e0;
61 font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
62 margin: 0;
63 padding: 0.5rem;
64 display: flex;
65 justify-content: center;
66 align-items: flex-start;
67 min-height: 100vh;
68 box-sizing: border-box;
69 }
70 .card {
71 background-color: #212121;
72 border-radius: 0.5rem;
73 padding: 1rem;
74 width: 100%;
75 max-width: 600px;
76 min-width: 280px;
77 box-sizing: border-box;
78 box-shadow: 0 0.0625rem 0.1875rem rgba(0,0,0,0.3);
79 position: relative; /* For absolute positioning of api-status */
80 }
81 .card-header {
82 font-size: 1rem;
83 color: #03a9f4;
84 margin: 0 0 0.75rem 0;
85 font-weight: 500;
86 display: flex;
87 align-items: center;
88 gap: 0.5rem;
89 }
90 .data-grid {
91 display: grid;
92 grid-template-columns: auto 1fr;
93 gap: 0.5rem;
94 font-size: 0.875rem;
95 }
96 .data-label {
97 color: #b0b0b0;
98 font-weight: bold;
99 padding-right: 0.5rem;
100 }
101 .data-value {
102 color: #e0e0e0;
103 }
104 .settings {
105 margin-top: 0.75rem;
106 padding-top: 0.75rem;
107 border-top: 0.0625rem solid #424242;
108 }
109 .settings-row {
110 display: flex;
111 gap: 0.5rem;
112 flex-wrap: wrap;
113 align-items: center;
114 }
115 label {
116 color: #b0b0b0;
117 font-size: 0.875rem;
118 font-weight: bold;
119 }
120 input[type="number"] {
121 background-color: #424242;
122 color: #e0e0e0;
123 border: 0.0625rem solid #616161;
124 border-radius: 0.25rem;
125 padding: 0.25rem;
126 font-size: 0.875rem;
127 width: 3.75rem;
128 box-sizing: border-box;
129 }
130 button {
131 background-color: #03a9f4;
132 color: #fff;
133 border: none;
134 border-radius: 0.25rem;
135 padding: 0.375rem 0.75rem;
136 cursor: pointer;
137 font-size: 0.875rem;
138 transition: background-color 0.2s;
139 }
140 button:hover {
141 background-color: #0288d1;
142 }
143 .track-button {
144 background-color: #4caf50;
145 width: 6.25rem;
146 }
147 .track-button:hover {
148 background-color: #43a047;
149 }
150 .track-button:disabled {
151 background-color: #616161;
152 cursor: not-allowed;
153 }
154 #status {
155 margin-top: 0.5rem;
156 font-size: 0.75rem;
157 color: #757575;
158 text-align: center;
159 }
160 .loading-spinner {
161 display: inline-block;
162 width: 16px;
163 height: 16px;
164 border: 2px solid #03a9f4;
165 border-radius: 50%;
166 border-top-color: transparent;
167 animation: spin 1s linear infinite;
168 vertical-align: middle;
169 }
170 @keyframes spin {
171 to {
172 transform: rotate(360deg);
173 }
174 }
175 .success-icon {
176 color: #4caf50;
177 vertical-align: middle;
178 }
179 .api-status {
180 position: absolute;
181 bottom: 1rem;
182 right: 1rem;
183 display: flex;
184 align-items: center;
185 gap: 0.25rem;
186 font-size: 0.875rem;
187 }
188 .api-label {
189 color: rgba(176, 176, 176, 0.5); /* Semi-transparent #b0b0b0 */
190 font-weight: bold;
191 }
192 @media (max-width: 400px) {
193 .card {
194 padding: 0.75rem;
195 min-width: 200px;
196 }
197 .card-header {
198 font-size: 0.875rem;
199 }
200 .data-grid {
201 font-size: 0.75rem;
202 gap: 0.375rem;
203 }
204 input[type="number"], button {
205 font-size: 0.75rem;
206 padding: 0.2rem;
207 }
208 .track-button {
209 width: 4.5rem;
210 }
211 input[type="number"] {
212 width: 3rem;
213 }
214 .api-status {
215 bottom: 0.75rem;
216 right: 0.75rem;
217 font-size: 0.75rem;
218 }
219 .loading-spinner {
220 width: 12px;
221 height: 12px;
222 border-width: 2px;
223 }
224 }
225 @media (min-width: 600px) {
226 .card {
227 padding: 1.25rem;
228 }
229 .card-header {
230 font-size: 1.125rem;
231 }
232 .data-grid {
233 font-size: 1rem;
234 }
235 input[type="number"], button {
236 font-size: 1rem;
237 padding: 0.375rem;
238 }
239 .api-status {
240 bottom: 1.25rem;
241 right: 1.25rem;
242 }
243 }
244 </style>
245</head>
246<body>
247 <div class="card">
248 <div class="card-header">
249 <span>Aircraft Tracker</span>
250 <span id="status-icon"></span>
251 </div>
252 <div class="data-grid" id="aircraft-data">
253 <span class="data-label">Callsign:</span>
254 <span class="data-value" id="callsign">N/A</span>
255 <span class="data-label">Type:</span>
256 <span class="data-value" id="type">N/A</span>
257 <span class="data-label">Altitude:</span>
258 <span class="data-value" id="altitude">0 ft</span>
259 <span class="data-label">Speed:</span>
260 <span class="data-value" id="speed">0 mph</span>
261 <span class="data-label">Direction:</span>
262 <span class="data-value" id="direction">0°</span>
263 <span class="data-label">Distance:</span>
264 <span class="data-value" id="distance">0 NM</span>
265 <span class="data-label">Last Seen:</span>
266 <span class="data-value" id="seen">0 s ago</span>
267 <span class="data-label">Today:</span>
268 <span class="data-value" id="count">0</span>
269 </div>
270 <div class="settings">
271 <form id="settings-form">
272 <div class="settings-row">
273 <label for="radius">Radius (nm):</label>
274 <input type="number" id="radius" name="radius" min="1" max="50" value="%RADIUS%">
275 <button type="submit">Apply</button>
276 <button type="button" id="track-button" class="track-button" disabled>Track</button>
277 </div>
278 </form>
279 <div id="status"></div>
280 </div>
281 <div class="api-status">
282 <span class="api-label">API:</span>
283 <span id="loading-status"></span>
284 </div>
285 </div>
286 <script>
287 const statusIcon = document.getElementById('status-icon');
288 const loadingStatus = document.getElementById('loading-status');
289
290 function setLoadingState(isLoading) {
291 if (isLoading) {
292 loadingStatus.innerHTML = '<span class="loading-spinner"></span>';
293 } else {
294 loadingStatus.innerHTML = '<span class="success-icon">✅</span>';
295 }
296 }
297
298 function fetchData() {
299 setLoadingState(true);
300
301 fetch('/data')
302 .then(response => response.json())
303 .then(data => {
304 setLoadingState(false);
305 statusIcon.textContent = data.hasData ? '🛩️: Aircraft Detected' : '🚫 No Aircraft Detected';
306 document.getElementById('callsign').textContent = data.callsign || 'N/A';
307 document.getElementById('type').textContent = data.type || 'N/A';
308 document.getElementById('altitude').textContent = data.altitude + ' ft';
309 document.getElementById('speed').textContent = Math.round(data.speed * 1.15078) + ' mph';
310 document.getElementById('direction').textContent = data.direction + '°';
311 document.getElementById('distance').textContent = data.distance.toFixed(1) + ' NM';
312 document.getElementById('seen').textContent = data.seen.toFixed(0) + ' s ago';
313 document.getElementById('count').textContent = data.count;
314
315 const trackButton = document.getElementById('track-button');
316 if (data.hasData && data.hex && data.hex !== 'N/A') {
317 trackButton.disabled = false;
318 trackButton.onclick = () => {
319 window.open(`https://globe.adsbexchange.com/?icao=${data.hex}`, '_blank');
320 };
321 } else {
322 trackButton.disabled = true;
323 trackButton.onclick = null;
324 }
325 })
326 .catch(error => {
327 setLoadingState(false);
328 console.error('Error fetching data:', error);
329 });
330 }
331
332 setInterval(fetchData, 2000);
333 fetchData();
334
335 document.getElementById('settings-form').addEventListener('submit', function(e) {
336 e.preventDefault();
337 const radius = document.getElementById('radius').value;
338 fetch('/settings', {
339 method: 'POST',
340 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
341 body: `radius=${radius}`
342 })
343 .then(response => response.text())
344 .then(text => {
345 document.getElementById('status').textContent = 'Settings updated';
346 setTimeout(() => document.getElementById('status').textContent = '', 3000);
347 })
348 .catch(error => {
349 document.getElementById('status').textContent = 'Error updating settings';
350 console.error('Error:', error);
351 });
352 });
353 </script>
354</body>
355</html>
356)rawliteral";
357// Function to infer aircraft type
358String getAircraftType(String model, String callsign) {
359 model.toUpperCase();
360 callsign.toUpperCase();
361 String callsignPrefix = callsign.length() >= 3 ? callsign.substring(0, 3) : callsign;
362
363 if (model.startsWith("B737") || model.startsWith("B747") || model.startsWith("B757") ||
364 model.startsWith("B767") || model.startsWith("B777") || model.startsWith("B787") ||
365 model.startsWith("A220") || model.startsWith("A320") || model.startsWith("A330") ||
366 model.startsWith("A350") || model.startsWith("A380") || model.startsWith("E170") ||
367 model.startsWith("E190") || model.startsWith("CRJ") || model.startsWith("Q400") ||
368 model.startsWith("ATR") || model.startsWith("B38M")) {
369 return "Passenger";
370 }
371 if (callsignPrefix == "RYR" || callsignPrefix == "BAW" || callsignPrefix == "SHT" ||
372 callsignPrefix == "EZY" || callsignPrefix == "EJU" || callsignPrefix == "EXS" ||
373 callsignPrefix == "TOM" || callsignPrefix == "VIR" || callsignPrefix == "WUK" ||
374 callsignPrefix == "LOG" || callsignPrefix == "AUR" || callsignPrefix == "UAL" ||
375 callsignPrefix == "AAL" || callsignPrefix == "DAL" || callsignPrefix == "SWA" ||
376 callsignPrefix == "QFA" || callsignPrefix == "ANZ" || callsignPrefix == "SIA" ||
377 callsignPrefix == "JAL" || callsignPrefix == "CPA" || callsignPrefix == "UAE" ||
378 callsignPrefix == "QTR" || callsignPrefix == "ETD" || callsignPrefix == "AFR" ||
379 callsignPrefix == "DLH" || callsignPrefix == "EFW" || callsignPrefix == "KLM" ||
380 callsignPrefix == "IBE" || callsignPrefix == "AZA" || callsignPrefix == "SWR" ||
381 callsignPrefix == "THY" || callsignPrefix == "ETR" || callsignPrefix == "SAA" ||
382 callsignPrefix == "KQA" || callsignPrefix == "TAM" || callsignPrefix == "ARG" ||
383 callsignPrefix == "AVA" || callsignPrefix == "BCI" || callsignPrefix == "TAP") {
384 return "Passenger";
385 }
386 if (model.startsWith("C130") || model.startsWith("C17") || model.startsWith("F16") ||
387 model.startsWith("F18") || model.startsWith("F35") || model.startsWith("KC135") ||
388 model.startsWith("H60") || model.startsWith("CH47") || model.startsWith("MQ9") ||
389 model.startsWith("P8") || model.startsWith("A400") || model.startsWith("EUFI") ||
390 callsign.startsWith("RRR") || callsign.startsWith("NAVY") || callsign.startsWith("ASCOT") ||
391 callsign.startsWith("VANDAL") || callsign.startsWith("TARTAN") ||
392 callsignPrefix == "RFR" || callsignPrefix == "NOH" || callsignPrefix == "KIN" ||
393 callsignPrefix == "LCS" || callsignPrefix == "WAD" || callsignPrefix == "KRF" ||
394 callsignPrefix == "KRH" || callsignPrefix == "NVY" || callsignPrefix == "AIO" ||
395 callsignPrefix == "PAT" || callsignPrefix == "CNV" || callsignPrefix == "CGX" ||
396 callsignPrefix == "RFF" || callsignPrefix == "CHD" || callsignPrefix == "TTF" ||
397 callsignPrefix == "BRK") {
398 return "Military";
399 }
400 if (model.startsWith("EC35") || model.startsWith("H135") || model.startsWith("EC45") ||
401 model.startsWith("AS355") || callsign.startsWith("NPAS") || callsign.indexOf("POL") >= 0) {
402 return "Police";
403 }
404 if (model.startsWith("BE20") || model.startsWith("PC12") || model.startsWith("EC45") ||
405 model.startsWith("H145") || callsign.indexOf("MED") >= 0 || callsign.indexOf("AMB") >= 0 ||
406 callsign.startsWith("HELI") || callsign.indexOf("AIRE") >= 0 ||
407 callsignPrefix == "HLE" || callsignPrefix == "FLYDOC" || callsign.startsWith("MEDEVAC") ||
408 callsign.startsWith("EVAC") || callsign.startsWith("HOSP") || callsignPrefix == "RED") {
409 return "Ambulance";
410 }
411 if (model.startsWith("S92") || model.startsWith("AW189") || callsign.startsWith("G-") ||
412 callsign.indexOf("RESCUE") >= 0 || callsign.indexOf("COAST") >= 0 ||
413 callsign.startsWith("COASTGUARD") || callsignPrefix == "RES") {
414 return "Coast Guard";
415 }
416 if (model.startsWith("B74F") || model.startsWith("B75F") || model.startsWith("AN12") ||
417 model.startsWith("C130") || callsign.indexOf("FEDEX") >= 0 || callsign.indexOf("UPS") >= 0 ||
418 callsign.indexOf("DHL") >= 0 || callsignPrefix == "GTI") {
419 return "Cargo";
420 }
421 if (model.startsWith("GLF") || model.startsWith("CL60") || model.startsWith("C25") ||
422 model.startsWith("LJ") || model.startsWith("F2TH") || model.startsWith("FA7X")) {
423 return "Private Jet";
424 }
425 if (model.startsWith("C172") || model.startsWith("C152") || model.startsWith("P28A") ||
426 model.startsWith("SR22") || model.startsWith("DA40") || model.startsWith("PA46")) {
427 return "General";
428 }
429 return "Unknown";
430}
431
432void setup() {
433 // Initialize LED pin
434 pinMode(LED_PIN, OUTPUT);
435 analogWriteRange(1023); // Set PWM range to 1023
436 digitalWrite(LED_PIN, HIGH); // Ensure LED is off (active-low)
437
438 Serial.begin(115200);
439 Serial.println("Setup started...");
440
441 // Connect to WiFi
442 Serial.println("Connecting to WiFi...");
443 WiFi.begin(ssid, password);
444 int attempts = 0;
445 while (WiFi.status() != WL_CONNECTED && attempts < 20) {
446 delay(500);
447 Serial.print(".");
448 attempts++;
449 }
450 if (WiFi.status() == WL_CONNECTED) {
451 Serial.println("\nWiFi connected");
452 Serial.print("IP Address: ");
453 Serial.println(WiFi.localIP());
454 } else {
455 Serial.println("\nWiFi failed, restarting...");
456 delay(1000);
457 ESP.restart();
458 }
459
460 // Setup web server routes
461 server.on("/", HTTP_GET, []() {
462 String page = htmlPage;
463 page.replace("%RADIUS%", String(RADIUS));
464 page.replace("%STATUS_ICON%", hasAircraftData ? "🛫" : "—");
465 server.send(200, "text/html", page);
466 });
467
468 server.on("/data", HTTP_GET, []() {
469 DynamicJsonDocument doc(256);
470 doc["hasData"] = hasAircraftData;
471 doc["callsign"] = currentCallsign;
472 doc["type"] = currentType;
473 doc["altitude"] = currentAlt;
474 doc["speed"] = currentSpeed;
475 doc["direction"] = currentDirection;
476 doc["distance"] = currentDistance;
477 doc["seen"] = seenTime;
478 doc["count"] = aircraftCount;
479 doc["hex"] = currentHex;
480 String json;
481 serializeJson(doc, json);
482 server.send(200, "application/json", json);
483 });
484
485 server.on("/settings", HTTP_POST, []() {
486 String response = "Settings updated";
487
488 if (server.hasArg("radius")) {
489 int newRadius = server.arg("radius").toInt();
490 if (newRadius >= 1 && newRadius <= 50) {
491 RADIUS = newRadius;
492 Serial.println("Radius updated to: " + String(RADIUS));
493 } else {
494 response = "Invalid radius value";
495 Serial.println("Invalid radius: " + server.arg("radius"));
496 }
497 }
498
499 server.send(200, "text/plain", response);
500 });
501
502 server.begin();
503 Serial.println("Web server started");
504
505 // Initialize reset time
506 lastResetTime = millis();
507}
508
509void updateMainDisplay() {
510 if (WiFi.status() != WL_CONNECTED) {
511 hasAircraftData = false;
512 return;
513 }
514
515 WiFiClientSecure client;
516 client.setInsecure();
517 HTTPClient http;
518 Serial.println("Fetching API data...");
519
520 char url[128];
521 snprintf(url, sizeof(url), "https://api.adsb.lol/v2/lat/%.4f/lon/%.4f/dist/%d", LAT, LON, RADIUS);
522 Serial.print("URL: ");
523 Serial.println(url);
524
525 if (http.begin(client, url)) {
526 int httpCode = http.GET();
527 Serial.print("HTTP Code: ");
528 Serial.println(httpCode);
529
530 if (httpCode == HTTP_CODE_OK) {
531 WiFiClient *stream = http.getStreamPtr();
532 StaticJsonDocument<200> filter;
533 filter["ac"][0]["flight"] = true;
534 filter["ac"][0]["alt_baro"] = true;
535 filter["ac"][0]["gs"] = true;
536 filter["ac"][0]["track"] = true;
537 filter["ac"][0]["lat"] = true;
538 filter["ac"][0]["lon"] = true;
539 filter["ac"][0]["seen"] = true;
540 filter["ac"][0]["t"] = true;
541 filter["ac"][0]["hex"] = true;
542
543 DynamicJsonDocument doc(3072);
544 DeserializationError error = deserializeJson(doc, *stream, DeserializationOption::Filter(filter));
545 if (error) {
546 Serial.println(F("JSON parse error: ") + String(error.c_str()));
547 hasAircraftData = false;
548 } else {
549 JsonArray aircraftList = doc["ac"];
550 if (aircraftList.size() > 0) {
551 float min_distance = 9999.0;
552 JsonObject closest_aircraft;
553 for (JsonObject aircraft : aircraftList) {
554 float aircraft_lat = aircraft["lat"] | 0.0;
555 float aircraft_lon = aircraft["lon"] | 0.0;
556 float delta_lat = aircraft_lat - LAT;
557 float delta_lon = (aircraft_lon - LON) * cos(LAT * PI / 180.0);
558 float distance = sqrt(delta_lat * delta_lat + delta_lon * delta_lon) * 60.0;
559 if (distance < min_distance) {
560 min_distance = distance;
561 closest_aircraft = aircraft;
562 }
563 }
564
565 String newCallsign = closest_aircraft["flight"] | "N/A";
566 newCallsign.trim();
567 bool isUnique = true;
568 for (int i = 0; i < seenCallsignsIndex; i++) {
569 if (seenCallsigns[i] == newCallsign) {
570 isUnique = false;
571 break;
572 }
573 }
574 if (isUnique && newCallsign != "N/A" && seenCallsignsIndex < 50) {
575 seenCallsigns[seenCallsignsIndex++] = newCallsign;
576 aircraftCount++;
577 Serial.print("New aircraft detected. Total today: ");
578 Serial.println(aircraftCount);
579 }
580
581 currentCallsign = newCallsign;
582 String aircraftModel = closest_aircraft["t"] | "Unknown";
583 currentType = getAircraftType(aircraftModel, currentCallsign);
584 currentAlt = closest_aircraft["alt_baro"] | 0;
585 currentSpeed = closest_aircraft["gs"] | 0.0;
586 currentDirection = closest_aircraft["track"] | 0.0;
587 currentDistance = min_distance;
588 float apiSeenTime = closest_aircraft["seen"] | 0.0;
589 currentHex = closest_aircraft["hex"] | "N/A";
590
591 lastDataTime = millis();
592 initialSeenTime = apiSeenTime;
593 seenTime = apiSeenTime;
594 hasAircraftData = true;
595 Serial.println("Aircraft data updated");
596 Serial.print("Free Heap: ");
597 Serial.println(ESP.getFreeHeap());
598 } else {
599 Serial.println("No aircraft found");
600 hasAircraftData = false;
601 currentHex = "N/A";
602 }
603 }
604 } else {
605 hasAircraftData = false;
606 currentHex = "N/A";
607 }
608 http.end();
609 } else {
610 hasAircraftData = false;
611 currentHex = "N/A";
612 }
613}
614
615void updateLED() {
616 if (hasAircraftData) {
617 // Fade in and out
618 unsigned long currentTime = millis();
619 if (currentTime - lastFadeUpdate >= FADE_INTERVAL) {
620 if (fadeDirection) {
621 fadeValue += 10; // Increase brightness
622 if (fadeValue >= 1023) {
623 fadeValue = 1023;
624 fadeDirection = false;
625 }
626 } else {
627 fadeValue -= 10; // Decrease brightness
628 if (fadeValue <= 0) {
629 fadeValue = 0;
630 fadeDirection = true;
631 }
632 }
633 // Apply PWM (invert for active-low LED)
634 analogWrite(LED_PIN, 1023 - fadeValue);
635 lastFadeUpdate = currentTime;
636 }
637 } else {
638 // Turn LED off
639 fadeValue = 0;
640 digitalWrite(LED_PIN, HIGH); // Active-low, so HIGH is off
641 }
642}
643
644void loop() {
645 server.handleClient();
646
647 unsigned long currentTime = millis();
648
649 // Reset counter if 24 hours have passed
650 if (currentTime - lastResetTime >= DAY_LENGTH) {
651 aircraftCount = 0;
652 seenCallsignsIndex = 0;
653 for (int i = 0; i < 50; i++) {
654 seenCallsigns[i] = "";
655 }
656 lastResetTime = currentTime;
657 Serial.println("Daily aircraft counter reset");
658 }
659
660 // Update seen time every second
661 if (currentTime - lastUpdate >= 1000) {
662 if (lastDataTime > 0) {
663 seenTime = initialSeenTime + (currentTime - lastDataTime) / 1000.0;
664 } else {
665 seenTime = 0;
666 }
667 }
668
669 // Update main data every 8 seconds
670 if (currentTime - lastUpdate >= UPDATE_INTERVAL) {
671 updateMainDisplay();
672 lastUpdate = currentTime;
673 Serial.println("Main data updated");
674 }
675
676 // Update LED state
677 updateLED();
678}