A portable ground station for LoRa drone and satellite communications
Devices and components
LILYGO T3-S3 Meshtastic Version – LoRa SX1262 868 MHz
Materials and tools
3D printer
Soldering iron
Software and tools
Arduino IDE
VS Code
Project description
Abstract
Introduction
Pin mapping
Construction stages
full code
1#include <SPI.h>
2#include <RadioLib.h>
3#include <U8g2lib.h>
4#include <Wire.h>
5#include <WiFi.h>
6#include <WebServer.h>
7#include <esp_wifi.h>
8#include <EEPROM.h>
9
10// Forward declarations
11void IRAM_ATTR setFlag();
12void IRAM_ATTR isr_up();
13void IRAM_ATTR isr_down();
14void IRAM_ATTR isr_sel();
15void IRAM_ATTR isr_ping();
16void startRx();
17void sendPing();
18void sendHeartbeat();
19void clearAll();
20void stopAPRSScanner();
21void startAPRSScanner();
22void drawHeader(const char* title);
23void applyLoRaSettings();
24void saveLoRaSettings();
25String getWebPage();
26void performSpectrumScan();
27
28// ==================== Pins ====================
29#define LORA_SCK 5
30#define LORA_MISO 3
31#define LORA_MOSI 6
32#define LORA_CS 7
33#define LORA_RST 8
34#define LORA_DIO1 33
35#define LORA_BUSY 34
36#define OLED_SDA 18
37#define OLED_SCL 17
38#define LED_PIN 37
39#define BAT_PIN 1
40#define BTN_UP 36
41#define BTN_DOWN 37
42#define BTN_SEL 38
43#define BTN_PING 40
44
45// ==================== LoRa Defaults ====================
46#define LORA_DEFAULT_FREQ 868.0
47#define LORA_DEFAULT_BW 125.0
48#define LORA_DEFAULT_SF 9
49#define LORA_DEFAULT_CR 7
50#define LORA_DEFAULT_SYNC 0x12
51#define LORA_DEFAULT_POWER 14
52
53// ==================== EEPROM ====================
54#define EEPROM_SIZE 32
55#define MAGIC_ADDR 0
56#define MAGIC_VALUE 0xAD
57#define LORA_CONFIG_ADDR 4
58
59struct LoRaSettings {
60 float frequency;
61 float bandwidth;
62 uint8_t spreadingFactor;
63 uint8_t codingRate;
64 uint8_t syncWord;
65 uint8_t power;
66} loraConfig;
67
68// ==================== WiFi AP ====================
69#define AP_SSID "LORA_AP"
70#define AP_PASS "lora1234"
71#define AP_IP "192.168.4.1"
72WebServer server(80);
73bool webModeActive = false;
74
75// ==================== Objects ====================
76SX1262 radio = new Module(LORA_CS, LORA_DIO1, LORA_RST, LORA_BUSY);
77U8G2_SSD1306_128X64_NONAME_F_SW_I2C display(U8G2_R2, OLED_SCL, OLED_SDA, U8X8_PIN_NONE);
78
79// ==================== ISR flags ====================
80volatile bool rxFlag = false;
81
82// ==================== FAST BUTTON SYSTEM ====================
83#define DEBOUNCE_US 25000UL
84volatile struct {
85 bool pending;
86 uint32_t lastUs;
87} btnState[4];
88
89void IRAM_ATTR isr_btn(int idx) {
90 uint32_t now = micros();
91 if ((now - btnState[idx].lastUs) > DEBOUNCE_US) {
92 btnState[idx].pending = true;
93 btnState[idx].lastUs = now;
94 }
95}
96void IRAM_ATTR isr_up() { isr_btn(0); }
97void IRAM_ATTR isr_down() { isr_btn(1); }
98void IRAM_ATTR isr_sel() { isr_btn(2); }
99void IRAM_ATTR isr_ping() { isr_btn(3); }
100
101int getKey() {
102 for (int i = 0; i < 4; i++) {
103 if (btnState[i].pending) {
104 btnState[i].pending = false;
105 return i;
106 }
107 }
108 return -1;
109}
110
111// ==================== State Machine ====================
112enum Screen {
113 MAIN_MENU, LORA_SCANNER, COMMS_LOG, DASHBOARD, RSSI_GRAPH,
114 WIFI_SNIFFER, APRS_ISS, LORA_SETTINGS, SPECTRUM_ANALYZER, WEB_CONTROL
115};
116Screen currentScreen = MAIN_MENU;
117int menuIndex = 0;
118const char* menuItems[] = {
119 "LoRa Scanner", "Comms Log", "Dashboard", "RSSI Graph",
120 "WiFi Sniffer", "APRS (ISS)", "LoRa Settings", "Spectrum", "Web Control"
121};
122const int menuSize = 9;
123
124// ==================== Statistics ====================
125int rxCount = 0, txCount = 0;
126float lastRSSI = -999, lastSNR = 0;
127float bestRSSI = -999;
128String lastData = "";
129float batteryVoltage = 0.0;
130int batteryPercent = 100;
131uint32_t startTime = 0;
132float avgRSSI = 0;
133int rssiSamples = 0;
134int crcErrors = 0;
135float linkBudget = 0;
136
137#define HISTORY_LEN 60
138float rssiHistory[HISTORY_LEN];
139float snrHistory[HISTORY_LEN];
140uint32_t histTime[HISTORY_LEN];
141uint8_t histIdx = 0;
142bool histFull = false;
143
144#define LOG_LEN 50
145String msgLog[LOG_LEN];
146float msgRSSI[LOG_LEN];
147float msgSNR[LOG_LEN];
148uint32_t msgTime[LOG_LEN];
149uint8_t logIdx = 0;
150bool logFull = false;
151
152// WiFi sniffer
153#define MAX_SSIDS 12
154String ssidList[MAX_SSIDS];
155int ssidRSSI[MAX_SSIDS];
156int ssidCount = 0;
157bool wifiScanDone = false;
158
159// Spectrum
160int spectrumValues[64] = {0};
161bool spectrumScanning = false;
162int spectrumFreqStart = 860;
163int spectrumFreqEnd = 880;
164int spectrumSteps = 64;
165
166// Repeating TX mode
167bool autoTxMode = false;
168uint32_t autoTxInterval = 5000;
169uint32_t lastAutoTx = 0;
170String autoTxMsg = "LORA:BEACON";
171
172// ==================== Timers ====================
173uint32_t lastTxTime = 0;
174uint32_t lastBatteryRead = 0;
175bool aprsModeActive = false;
176
177// ==================== EEPROM ====================
178void loadLoRaSettings() {
179 if (EEPROM.read(MAGIC_ADDR) == MAGIC_VALUE) {
180 EEPROM.get(LORA_CONFIG_ADDR, loraConfig);
181 } else {
182 loraConfig = {LORA_DEFAULT_FREQ, LORA_DEFAULT_BW, LORA_DEFAULT_SF,
183 LORA_DEFAULT_CR, LORA_DEFAULT_SYNC, LORA_DEFAULT_POWER};
184 saveLoRaSettings();
185 }
186}
187
188void saveLoRaSettings() {
189 EEPROM.put(LORA_CONFIG_ADDR, loraConfig);
190 EEPROM.write(MAGIC_ADDR, MAGIC_VALUE);
191 EEPROM.commit();
192}
193
194void applyLoRaSettings() {
195 radio.setFrequency(loraConfig.frequency);
196 radio.setBandwidth(loraConfig.bandwidth);
197 radio.setSpreadingFactor(loraConfig.spreadingFactor);
198 radio.setCodingRate(loraConfig.codingRate);
199 radio.setSyncWord(loraConfig.syncWord);
200 radio.setOutputPower(loraConfig.power);
201}
202
203// ==================== Helpers ====================
204int rssiToBar(float r) {
205 if (r < -150) r = -150; if (r > -30) r = -30;
206 return (int)(((r + 150.0f) / 120.0f) * 124.0f);
207}
208
209const char* rssiLabel(float r) {
210 if (r > -70) return "EXCL";
211 if (r > -85) return "GOOD";
212 if (r > -100) return "FAIR";
213 if (r > -115) return "WEAK";
214 return "NONE";
215}
216
217float estimateRange(float rssi, float txPower) {
218 float pathLoss = txPower - rssi;
219 float d = pow(10.0, (pathLoss - 32.4 - 20.0*log10(loraConfig.frequency)) / 20.0);
220 return max(0.01f, d);
221}
222
223float calcAirtime() {
224 int sf = loraConfig.spreadingFactor;
225 float bw = loraConfig.bandwidth * 1000;
226 float ts = pow(2, sf) / bw;
227 float tpream = (8 + 4.25) * ts * 1000;
228 int payloadBytes = 20;
229 int cr = loraConfig.codingRate - 4;
230 float payloadSymbols = 8 + max(0, (int)ceil((8.0*payloadBytes - 4*sf + 28 + 16) / (4*sf)) * (cr+4));
231 float tpayload = payloadSymbols * ts * 1000;
232 return tpream + tpayload;
233}
234
235void addHistory(float r, float s) {
236 rssiHistory[histIdx] = r;
237 snrHistory[histIdx] = s;
238 histTime[histIdx] = millis() / 1000;
239 histIdx = (histIdx + 1) % HISTORY_LEN;
240 if (histIdx == 0) histFull = true;
241 avgRSSI = (avgRSSI * rssiSamples + r) / (rssiSamples + 1);
242 rssiSamples++;
243}
244
245void addToLog(const String& data, float rssi, float snr) {
246 msgLog[logIdx] = data;
247 msgRSSI[logIdx] = rssi;
248 msgSNR[logIdx] = snr;
249 msgTime[logIdx] = millis() / 1000;
250 logIdx = (logIdx + 1) % LOG_LEN;
251 if (logIdx == 0) logFull = true;
252}
253
254float getBatteryVoltage() {
255 int raw = analogRead(BAT_PIN);
256 return (raw * 3.3f / 8191.0f) * 2.0f;
257}
258
259int getBatteryPercent(float v) {
260 if (v > 4.1) return 100;
261 if (v < 3.3) return 0;
262 return (int)((v - 3.3f) * 125.0f);
263}
264
265// ==================== WiFi Sniffer ====================
266void snifferCallback(void* buf, wifi_promiscuous_pkt_type_t type) {
267 if (type != WIFI_PKT_MGMT) return;
268 wifi_promiscuous_pkt_t *pkt = (wifi_promiscuous_pkt_t*)buf;
269 uint8_t *payload = pkt->payload;
270 uint16_t fc = payload[0] | (payload[1] << 8);
271 if (((fc >> 2) & 3) == 0 && ((fc >> 4) & 0xF) == 8) {
272 uint8_t *pos = payload + 24 + 12;
273 while (pos - payload < (int)pkt->rx_ctrl.sig_len - 2) {
274 uint8_t tag = pos[0], len = pos[1];
275 if (tag == 0 && len > 0 && len <= 32 && ssidCount < MAX_SSIDS) {
276 char ssid[33]; memcpy(ssid, pos+2, len); ssid[len] = 0;
277 bool dup = false;
278 for (int i = 0; i < ssidCount; i++) if (ssidList[i] == String(ssid)) { dup = true; break; }
279 if (!dup && strlen(ssid) > 0) {
280 ssidList[ssidCount] = String(ssid);
281 ssidRSSI[ssidCount] = pkt->rx_ctrl.rssi;
282 ssidCount++;
283 }
284 break;
285 }
286 pos += 2 + len;
287 }
288 }
289}
290
291void performStealthWiFiScan() {
292 if (webModeActive) return;
293 ssidCount = 0; wifiScanDone = false;
294 wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
295 esp_wifi_init(&cfg); esp_wifi_set_storage(WIFI_STORAGE_RAM);
296 esp_wifi_set_mode(WIFI_MODE_NULL); esp_wifi_start();
297 esp_wifi_set_promiscuous(true); esp_wifi_set_promiscuous_rx_cb(snifferCallback);
298 delay(5000);
299 esp_wifi_set_promiscuous(false); esp_wifi_stop(); esp_wifi_deinit();
300 wifiScanDone = true;
301}
302
303// ==================== Spectrum ====================
304void performSpectrumScan() {
305 spectrumScanning = true;
306 float freqStep = (float)(spectrumFreqEnd - spectrumFreqStart) / spectrumSteps;
307 for (int i = 0; i < spectrumSteps; i++) {
308 float freq = spectrumFreqStart + i * freqStep;
309 radio.setFrequency(freq);
310 delay(15);
311 float rssi = radio.getRSSI();
312 spectrumValues[i] = constrain((int)(rssi + 150), 0, 100);
313 if (i % 8 == 0) {
314 display.clearBuffer();
315 drawHeader("SPECTRUM SCAN");
316 display.setFont(u8g2_font_7x13_tf);
317 char buf[32]; snprintf(buf, sizeof(buf), "%.2f MHz", freq);
318 display.drawStr(8, 38, buf);
319 char prog[24]; snprintf(prog, sizeof(prog), "%d / %d", i+1, spectrumSteps);
320 display.drawStr(35, 54, prog);
321 display.sendBuffer();
322 }
323 }
324 radio.setFrequency(loraConfig.frequency);
325 spectrumScanning = false;
326}
327
328// ==================== APRS ====================
329void startAPRSScanner() { aprsModeActive = true; radio.startReceive(); }
330void stopAPRSScanner() { aprsModeActive = false; radio.startReceive(); }
331
332// ==================== LoRa TX ====================
333void sendHeartbeat() {
334 txCount++;
335 char msg[64]; snprintf(msg, sizeof(msg), "LORA:HB #%d UP:%lus", txCount, millis()/1000);
336 digitalWrite(LED_PIN, HIGH); radio.transmit(msg); digitalWrite(LED_PIN, LOW);
337 startRx();
338}
339
340void sendPing() {
341 txCount++;
342 char msg[32]; snprintf(msg, sizeof(msg), "LORA:PING #%d", txCount);
343 digitalWrite(LED_PIN, HIGH); radio.transmit(msg); digitalWrite(LED_PIN, LOW);
344 startRx();
345}
346
347void sendCustom(const String& msg) {
348 txCount++;
349 digitalWrite(LED_PIN, HIGH); radio.transmit(msg.c_str()); digitalWrite(LED_PIN, LOW);
350 addToLog("[TX] " + msg, 0, 0);
351 startRx();
352}
353
354void clearAll() {
355 rxCount = txCount = crcErrors = rssiSamples = 0;
356 bestRSSI = -999; avgRSSI = 0;
357 histIdx = 0; histFull = false;
358 logIdx = 0; logFull = false;
359}
360
361// ==================== Web Mode ====================
362void startWebMode() {
363 webModeActive = true;
364 WiFi.softAP(AP_SSID, AP_PASS);
365 delay(500);
366 server.on("/", HTTP_GET, []() { server.send(200, "text/html", getWebPage()); });
367 server.on("/api/status", HTTP_GET, []() {
368 float range = (lastRSSI > -900) ? estimateRange(lastRSSI, loraConfig.power) : 0;
369 float airtime = calcAirtime();
370 String json = "{";
371 json += "\"rxCount\":" + String(rxCount) + ",";
372 json += "\"txCount\":" + String(txCount) + ",";
373 json += "\"crcErrors\":" + String(crcErrors) + ",";
374 json += "\"rssi\":" + String(lastRSSI) + ",";
375 json += "\"snr\":" + String(lastSNR) + ",";
376 json += "\"avgRSSI\":" + String(avgRSSI) + ",";
377 json += "\"bestRSSI\":" + String(bestRSSI) + ",";
378 json += "\"estRange\":" + String(range) + ",";
379 json += "\"airtime\":" + String(airtime) + ",";
380 json += "\"lastData\":\"" + lastData + "\",";
381 json += "\"batVolt\":" + String(batteryVoltage) + ",";
382 json += "\"batPct\":" + String(batteryPercent) + ",";
383 json += "\"uptime\":" + String(millis()/1000) + ",";
384 json += "\"freq\":" + String(loraConfig.frequency) + ",";
385 json += "\"bw\":" + String(loraConfig.bandwidth) + ",";
386 json += "\"sf\":" + String(loraConfig.spreadingFactor) + ",";
387 json += "\"cr\":" + String(loraConfig.codingRate) + ",";
388 json += "\"sw\":" + String(loraConfig.syncWord) + ",";
389 json += "\"power\":" + String(loraConfig.power) + ",";
390 json += "\"autoTx\":" + String(autoTxMode ? "true" : "false") + ",";
391 json += "\"autoTxInterval\":" + String(autoTxInterval) + ",";
392 json += "\"autoTxMsg\":\"" + autoTxMsg + "\"";
393 json += "}";
394 server.send(200, "application/json", json);
395 });
396 server.on("/api/log", HTTP_GET, []() {
397 int total = logFull ? LOG_LEN : logIdx;
398 String json = "[";
399 for (int i = 0; i < total; i++) {
400 int idx = logFull ? (logIdx + i) % LOG_LEN : i;
401 if (i > 0) json += ",";
402 json += "{\"t\":" + String(msgTime[idx]) + ",\"d\":\"" + msgLog[idx] + "\",\"r\":" + String(msgRSSI[idx]) + ",\"s\":" + String(msgSNR[idx]) + "}";
403 }
404 json += "]";
405 server.send(200, "application/json", json);
406 });
407 server.on("/api/rssi_history", HTTP_GET, []() {
408 int total = histFull ? HISTORY_LEN : histIdx;
409 String json = "{\"rssi\":[";
410 for (int i = 0; i < total; i++) {
411 int idx = histFull ? (histIdx + i) % HISTORY_LEN : i;
412 if (i > 0) json += ",";
413 json += String(rssiHistory[idx]);
414 }
415 json += "],\"snr\":[";
416 for (int i = 0; i < total; i++) {
417 int idx = histFull ? (histIdx + i) % HISTORY_LEN : i;
418 if (i > 0) json += ",";
419 json += String(snrHistory[idx]);
420 }
421 json += "],\"t\":[";
422 for (int i = 0; i < total; i++) {
423 int idx = histFull ? (histIdx + i) % HISTORY_LEN : i;
424 if (i > 0) json += ",";
425 json += String(histTime[idx]);
426 }
427 json += "]}";
428 server.send(200, "application/json", json);
429 });
430 server.on("/api/spectrum", HTTP_GET, []() {
431 String json = "{\"values\":[";
432 for (int i = 0; i < spectrumSteps; i++) {
433 if (i > 0) json += ",";
434 json += String(spectrumValues[i]);
435 }
436 json += "],\"freqStart\":" + String(spectrumFreqStart) + ",\"freqEnd\":" + String(spectrumFreqEnd) + "}";
437 server.send(200, "application/json", json);
438 });
439 server.on("/api/wifi_scan", HTTP_GET, []() {
440 String json = "[";
441 for (int i = 0; i < ssidCount; i++) {
442 if (i > 0) json += ",";
443 json += "{\"ssid\":\"" + ssidList[i] + "\",\"rssi\":" + String(ssidRSSI[i]) + "}";
444 }
445 json += "]";
446 server.send(200, "application/json", json);
447 });
448 server.on("/api/ping", HTTP_POST, []() { sendPing(); server.send(200, "application/json", "{\"ok\":true}"); });
449 server.on("/api/heartbeat", HTTP_POST, []() { sendHeartbeat(); server.send(200, "application/json", "{\"ok\":true}"); });
450 server.on("/api/send", HTTP_POST, []() {
451 if (server.hasArg("msg")) {
452 sendCustom(server.arg("msg"));
453 server.send(200, "application/json", "{\"ok\":true}");
454 } else {
455 server.send(400, "application/json", "{\"error\":\"no msg\"}");
456 }
457 });
458 server.on("/api/set_lora", HTTP_POST, []() {
459 if (server.hasArg("freq")) loraConfig.frequency = server.arg("freq").toFloat();
460 if (server.hasArg("bw")) loraConfig.bandwidth = server.arg("bw").toFloat();
461 if (server.hasArg("sf")) loraConfig.spreadingFactor = server.arg("sf").toInt();
462 if (server.hasArg("cr")) loraConfig.codingRate = server.arg("cr").toInt();
463 if (server.hasArg("pw")) loraConfig.power = server.arg("pw").toInt();
464 if (server.hasArg("sw")) loraConfig.syncWord = (uint8_t)strtol(server.arg("sw").c_str(), NULL, 16);
465 saveLoRaSettings(); applyLoRaSettings();
466 server.send(200, "application/json", "{\"ok\":true}");
467 });
468 server.on("/api/set_autotx", HTTP_POST, []() {
469 if (server.hasArg("enabled")) autoTxMode = server.arg("enabled") == "1";
470 if (server.hasArg("interval")) autoTxInterval = server.arg("interval").toInt();
471 if (server.hasArg("msg")) autoTxMsg = server.arg("msg");
472 server.send(200, "application/json", "{\"ok\":true}");
473 });
474 server.on("/api/scan_spectrum", HTTP_POST, []() {
475 if (server.hasArg("start")) spectrumFreqStart = server.arg("start").toInt();
476 if (server.hasArg("end")) spectrumFreqEnd = server.arg("end").toInt();
477 server.send(200, "application/json", "{\"ok\":true}");
478 performSpectrumScan();
479 });
480 server.on("/api/clear", HTTP_POST, []() { clearAll(); server.send(200, "application/json", "{\"ok\":true}"); });
481 server.on("/api/wifi_start", HTTP_POST, []() { server.send(200, "application/json", "{\"ok\":true}"); });
482 server.onNotFound([]() {
483 server.sendHeader("Location", "http://" AP_IP "/");
484 server.send(302, "text/plain", "");
485 });
486 server.begin();
487}
488
489void stopWebMode() {
490 server.stop();
491 WiFi.softAPdisconnect(true);
492 WiFi.mode(WIFI_OFF);
493 esp_wifi_stop();
494 webModeActive = false;
495}
496
497// ==================== WEB PAGE (Black & Orange theme) ====================
498String getWebPage() {
499 return R"rawliteral(
500<!DOCTYPE html>
501<html lang="en">
502<head>
503<meta charset="UTF-8">
504<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
505<title>L.O.R.A. - LoRa Obsessive Radio Annoyer</title>
506<style>
507@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;500;700;900&display=swap');
508:root{
509 --bg:#000000; --panel:#0a0a0a; --panel2:#111111;
510 --border:#2a2a2a; --border2:#3a3a3a;
511 --accent:#ff8c00; --accent2:#ff6600;
512 --warn:#ffaa00; --danger:#ff3358; --purple:#b06dff;
513 --text:#e0e0e0; --dim:#6a6a6a;
514 --mono:'Share Tech Mono',monospace;
515 --ui:'Barlow Condensed',sans-serif;
516 --r:6px;
517}
518*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
519html,body{background:var(--bg);color:var(--text);font-family:var(--ui);min-height:100vh;font-size:15px;}
520body::after{content:'';position:fixed;inset:0;pointer-events:none;z-index:9999;
521 background:repeating-linear-gradient(0deg,transparent,transparent 3px,rgba(255,140,0,.03) 3px,rgba(255,140,0,.03) 4px);}
522.hdr{background:linear-gradient(180deg,#111111,#000000);border-bottom:1px solid var(--border2);
523 padding:10px 14px;display:flex;align-items:center;justify-content:space-between;
524 position:sticky;top:0;z-index:200;box-shadow:0 3px 20px rgba(255,140,0,.15);}
525.logo{font-weight:900;font-size:1.25rem;letter-spacing:4px;color:var(--accent);}
526.logo b{color:var(--accent2);}
527.hdr-right{display:flex;align-items:center;gap:10px;}
528.conn-pill{display:flex;align-items:center;gap:5px;padding:3px 10px;border-radius:20px;
529 background:rgba(255,140,0,.1);border:1px solid rgba(255,140,0,.3);font-size:.7rem;
530 letter-spacing:1px;color:var(--accent2);}
531.dot{width:7px;height:7px;border-radius:50%;background:var(--accent2);
532 box-shadow:0 0 6px var(--accent2);animation:blink 1.8s infinite;}
533@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
534.bat-badge{font-family:var(--mono);font-size:.72rem;color:var(--warn);}
535.tabs{display:flex;overflow-x:auto;background:var(--panel);border-bottom:1px solid var(--border);scrollbar-width:none;}
536.tabs::-webkit-scrollbar{display:none;}
537.tab{flex-shrink:0;padding:9px 14px;font-size:.7rem;font-weight:700;letter-spacing:2px;
538 text-transform:uppercase;color:var(--dim);cursor:pointer;border-bottom:2px solid transparent;
539 transition:all .18s;white-space:nowrap;}
540.tab.on{color:var(--accent);border-color:var(--accent);}
541.tab:active{background:rgba(255,140,0,.07);}
542.pg{display:none;padding:10px;}
543.pg.on{display:block;}
544.card{background:var(--panel);border:1px solid var(--border);border-radius:var(--r);
545 padding:12px;margin-bottom:10px;position:relative;overflow:hidden;}
546.card::before{content:'';position:absolute;top:0;left:0;right:0;height:1px;
547 background:linear-gradient(90deg,transparent,var(--accent2),transparent);opacity:.15;}
548.ctitle{font-size:.63rem;letter-spacing:2.5px;text-transform:uppercase;color:var(--dim);
549 margin-bottom:10px;font-weight:700;}
550.g2{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
551.g3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:7px;}
552.g4{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:6px;}
553.sbox{background:rgba(0,0,0,.5);border:1px solid var(--border);border-radius:5px;
554 padding:9px 6px;text-align:center;}
555.sv{font-family:var(--mono);font-size:1.2rem;line-height:1.1;margin-bottom:2px;color:var(--accent);}
556.sv.g{color:var(--accent2);}.sv.o{color:var(--warn);}.sv.r{color:var(--danger);}.sv.p{color:var(--purple);}
557.sl{font-size:.58rem;letter-spacing:1px;text-transform:uppercase;color:var(--dim);}
558.rtrack{height:7px;background:rgba(255,255,255,.05);border-radius:3px;overflow:hidden;margin:8px 0;}
559.rfill{height:100%;border-radius:3px;transition:width .4s,background .4s;}
560.btn{display:inline-flex;align-items:center;justify-content:center;gap:5px;
561 padding:9px 14px;border-radius:5px;border:none;cursor:pointer;
562 font-family:var(--ui);font-size:.72rem;font-weight:700;letter-spacing:1.5px;
563 text-transform:uppercase;transition:all .12s;user-select:none;}
564.btn:active{transform:scale(.95);filter:brightness(1.3);}
565.ba{background:rgba(255,140,0,.15);color:var(--accent);border:1px solid rgba(255,140,0,.4);}
566.bg{background:rgba(255,102,0,.1);color:var(--accent2);border:1px solid rgba(255,102,0,.35);}
567.bo{background:rgba(255,170,0,.1);color:var(--warn);border:1px solid rgba(255,170,0,.35);}
568.br{background:rgba(255,51,88,.1);color:var(--danger);border:1px solid rgba(255,51,88,.35);}
569.bp{background:rgba(176,109,255,.1);color:var(--purple);border:1px solid rgba(176,109,255,.35);}
570.brow{display:flex;gap:7px;flex-wrap:wrap;}
571.brow .btn{flex:1;min-width:70px;}
572.bfull{width:100%;margin-top:6px;}
573.lentry{padding:7px 10px;border-left:2px solid var(--border2);margin-bottom:5px;
574 background:rgba(0,0,0,.4);border-radius:0 4px 4px 0;transition:border-color .3s;}
575.lentry.rx{border-color:var(--accent2);}
576.lentry.tx{border-color:var(--accent);}
577.lentry.err{border-color:var(--danger);}
578.ldata{font-family:var(--mono);font-size:.78rem;color:var(--text);word-break:break-all;}
579.lmeta{font-size:.6rem;color:var(--dim);margin-top:3px;}
580.frow{margin-bottom:10px;}
581.flabel{font-size:.62rem;letter-spacing:1.5px;text-transform:uppercase;color:var(--dim);
582 margin-bottom:5px;display:block;}
583.finput{width:100%;background:rgba(0,0,0,.6);border:1px solid var(--border);
584 color:var(--text);padding:8px 11px;border-radius:5px;font-family:var(--mono);
585 font-size:.83rem;outline:none;transition:border-color .2s;}
586.finput:focus{border-color:var(--accent);}
587.fselect{width:100%;background:var(--panel2);border:1px solid var(--border);
588 color:var(--text);padding:8px 11px;border-radius:5px;font-family:var(--mono);font-size:.83rem;outline:none;}
589.srow{display:flex;align-items:center;gap:8px;}
590.frange{flex:1;accent-color:var(--accent);cursor:pointer;height:4px;}
591.sval{font-family:var(--mono);font-size:.82rem;color:var(--accent);min-width:50px;text-align:right;}
592.msgrow{display:flex;gap:7px;}
593.msgrow .finput{flex:1;}
594canvas{width:100%;display:block;border-radius:4px;}
595.tbl{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:.72rem;}
596.tbl td,.tbl th{padding:5px 8px;border-bottom:1px solid var(--border);}
597.tbl th{color:var(--dim);font-weight:normal;letter-spacing:1px;font-size:.6rem;text-transform:uppercase;}
598.tbl tr:last-child td{border-bottom:none;}
599.spwrap{display:flex;align-items:flex-end;gap:1px;height:90px;padding:0 2px;}
600.spbar{flex:1;border-radius:2px 2px 0 0;min-width:1px;transition:height .3s;}
601.toggle{position:relative;display:inline-block;width:40px;height:22px;}
602.toggle input{opacity:0;width:0;height:0;}
603.tslider{position:absolute;inset:0;background:#1a3a50;border-radius:22px;cursor:pointer;transition:.3s;}
604.tslider:before{content:'';position:absolute;height:16px;width:16px;left:3px;bottom:3px;
605 background:var(--dim);border-radius:50%;transition:.3s;}
606input:checked+.tslider{background:rgba(255,102,0,.25);border:1px solid var(--accent2);}
607input:checked+.tslider:before{transform:translateX(18px);background:var(--accent2);}
608.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%) translateY(20px);
609 background:var(--accent);color:#000;padding:7px 18px;border-radius:18px;
610 font-size:.78rem;font-weight:700;letter-spacing:1px;opacity:0;
611 transition:all .25s;z-index:9999;white-space:nowrap;pointer-events:none;}
612.toast.on{opacity:1;transform:translateX(-50%) translateY(0);}
613.div{height:1px;background:var(--border);margin:10px 0;}
614.kv{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;font-family:var(--mono);font-size:.75rem;align-items:center;}
615.kv .k{color:var(--dim);}
616.kv .v{color:var(--accent);}
617.mission-bar{display:flex;align-items:center;gap:8px;padding:8px 10px;
618 background:rgba(176,109,255,.07);border:1px solid rgba(176,109,255,.2);
619 border-radius:5px;margin-bottom:7px;font-size:.72rem;}
620.mission-icon{font-size:1.1rem;}
621.mission-name{font-weight:700;color:var(--purple);letter-spacing:1px;}
622.mission-sub{color:var(--dim);font-size:.65rem;}
623.sqm{display:flex;gap:3px;align-items:flex-end;height:20px;}
624.sqbar{width:6px;border-radius:2px;background:var(--border2);}
625@media(min-width:480px){.g4{grid-template-columns:repeat(4,1fr);}}
626</style>
627</head>
628<body>
629<div class="hdr">
630 <div class="logo">L.<b>O.</b>R.<b>A.</b></div>
631 <div class="hdr-right">
632 <div class="bat-badge" id="batHdr">--V</div>
633 <div class="conn-pill"><div class="dot" id="cDot"></div><span>LIVE</span></div>
634 </div>
635</div>
636<div class="tabs">
637 <div class="tab on" onclick="showTab('dash')">Dashboard</div>
638 <div class="tab" onclick="showTab('comms')">Comms</div>
639 <div class="tab" onclick="showTab('mission')">Mission</div>
640 <div class="tab" onclick="showTab('config')">Config</div>
641 <div class="tab" onclick="showTab('spectrum')">Spectrum</div>
642 <div class="tab" onclick="showTab('wifi')">WiFi</div>
643 <div class="tab" onclick="showTab('info')">Info</div>
644</div>
645<!-- ========== DASHBOARD ========== -->
646<div id="pg-dash" class="pg on">
647 <div class="card">
648 <div class="ctitle">Live Signal</div>
649 <div class="g2">
650 <div class="sbox"><div class="sv" id="dRSSI">---</div><div class="sl">RSSI dBm</div></div>
651 <div class="sbox"><div class="sv g" id="dSNR">---</div><div class="sl">SNR dB</div></div>
652 </div>
653 <div style="margin-top:8px">
654 <div style="display:flex;justify-content:space-between;font-size:.65rem;color:var(--dim);margin-bottom:3px">
655 <span>Signal Quality</span><span id="dQlabel" style="color:var(--accent)">---</span>
656 </div>
657 <div class="rtrack"><div class="rfill" id="dBar" style="width:0%"></div></div>
658 </div>
659 <div style="display:flex;justify-content:space-between;font-size:.68rem;margin-top:6px">
660 <span style="color:var(--dim)">Best: <span id="dBest" style="color:var(--accent2)">---</span></span>
661 <span style="color:var(--dim)">Avg: <span id="dAvg" style="color:var(--accent)">---</span></span>
662 <span style="color:var(--dim)">Est. Range: <span id="dRange" style="color:var(--warn)">---</span></span>
663 </div>
664 </div>
665 <div class="g4" style="margin-bottom:10px">
666 <div class="sbox"><div class="sv g" id="dRX">0</div><div class="sl">RX</div></div>
667 <div class="sbox"><div class="sv" id="dTX">0</div><div class="sl">TX</div></div>
668 <div class="sbox"><div class="sv r" id="dERR">0</div><div class="sl">CRC Err</div></div>
669 <div class="sbox"><div class="sv o" id="dBAT">--</div><div class="sl">Bat %</div></div>
670 </div>
671 <div class="card">
672 <div class="ctitle">RSSI History</div>
673 <canvas id="cRSSI" height="65"></canvas>
674 </div>
675 <div class="card">
676 <div class="ctitle">SNR History</div>
677 <canvas id="cSNR" height="45"></canvas>
678 </div>
679 <div class="card">
680 <div class="ctitle">System</div>
681 <div class="kv">
682 <span class="k">Voltage</span><span class="v" id="dVolt">-- V</span>
683 <span class="k">Uptime</span><span class="v" id="dUp">--</span>
684 <span class="k">Frequency</span><span class="v" id="dFreq">-- MHz</span>
685 <span class="k">SF / BW</span><span class="v" id="dSFBW">--</span>
686 <span class="k">Airtime/pkt</span><span class="v" id="dAirt">-- ms</span>
687 <span class="k">TX Power</span><span class="v" id="dPow">-- dBm</span>
688 </div>
689 </div>
690 <div class="brow">
691 <button class="btn bg" onclick="doPing()">⬛ PING</button>
692 <button class="btn ba" onclick="doHB()">♡ BEAT</button>
693 <button class="btn br" onclick="doClear()">✕ CLEAR</button>
694 </div>
695</div>
696<!-- ========== COMMS ========== -->
697<div id="pg-comms" class="pg">
698 <div class="card">
699 <div class="ctitle">Send Message</div>
700 <div class="frow" style="margin-bottom:8px">
701 <div class="msgrow">
702 <input id="msgIn" class="finput" type="text" placeholder="Enter message..." maxlength="200">
703 <button class="btn bg" onclick="doSend()">TX</button>
704 </div>
705 </div>
706 <div class="brow">
707 <button class="btn ba" onclick="doPing()">PING</button>
708 <button class="btn bo" onclick="doHB()">HEARTBEAT</button>
709 </div>
710 </div>
711 <div class="card">
712 <div class="ctitle">Auto-TX Beacon</div>
713 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
714 <span style="font-size:.75rem">Enable Auto-TX</span>
715 <label class="toggle"><input type="checkbox" id="autoTxChk" onchange="setAutoTx()"><span class="tslider"></span></label>
716 </div>
717 <div class="frow">
718 <label class="flabel">Beacon Message</label>
719 <input id="autoTxMsg" class="finput" type="text" value="LORA:BEACON" maxlength="100">
720 </div>
721 <div class="frow" style="margin-bottom:6px">
722 <label class="flabel">Interval: <span id="autoTxIntLbl">5</span>s</label>
723 <div class="srow"><input type="range" class="frange" id="autoTxInt" min="1" max="60" value="5">
724 <span class="sval" id="autoTxIntV">5s</span></div>
725 </div>
726 <button class="btn bo bfull" onclick="setAutoTx()">Apply Beacon Settings</button>
727 </div>
728 <div class="card">
729 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
730 <div class="ctitle" style="margin:0">Packet Log <span id="logCount" style="color:var(--accent)">(0)</span></div>
731 <button class="btn br" style="padding:4px 10px;font-size:.62rem" onclick="doClear()">CLR</button>
732 </div>
733 <div id="logBox" style="max-height:420px;overflow-y:auto">
734 <div style="color:var(--dim);text-align:center;padding:16px;font-size:.8rem">No packets</div>
735 </div>
736 </div>
737</div>
738<!-- ========== MISSION ========== -->
739<div id="pg-mission" class="pg">
740 <div class="card">
741 <div class="ctitle">Active Mission</div>
742 <div class="mission-bar"><span class="mission-icon">🚁</span>
743 <div><div class="mission-name">DRONE LINK</div><div class="mission-sub">2.4GHz Backup via LoRa</div></div>
744 </div>
745 <div class="mission-bar"><span class="mission-icon">🛰</span>
746 <div><div class="mission-name">SAT MONITOR</div><div class="mission-sub">ISS / Low-orbit beacon RX</div></div>
747 </div>
748 <div class="div"></div>
749 <div class="kv">
750 <span class="k">Link Budget</span><span class="v" id="mLB">-- dB</span>
751 <span class="k">Est. Range</span><span class="v" id="mRange">-- km</span>
752 <span class="k">Packets/min</span><span class="v" id="mPPM">--</span>
753 <span class="k">CRC Error Rate</span><span class="v" id="mCRC">--%</span>
754 <span class="k">Link Quality</span><span class="v" id="mLQ">--</span>
755 </div>
756 </div>
757 <div class="card">
758 <div class="ctitle">Telemetry Parser</div>
759 <div style="font-size:.72rem;color:var(--dim);margin-bottom:8px">Last packet parsed fields:</div>
760 <div id="teleFields" class="kv" style="font-size:.72rem">
761 <span class="k">Raw</span><span class="v" id="tfRaw">---</span>
762 </div>
763 </div>
764 <div class="card">
765 <div class="ctitle">Quick Presets</div>
766 <div class="brow">
767 <button class="btn bp" onclick="mPreset('drone')">🚁 Drone</button>
768 <button class="btn bp" onclick="mPreset('iss')">🛰 ISS/APRS</button>
769 <button class="btn bp" onclick="mPreset('sat')">📡 Sat Uplink</button>
770 <button class="btn bp" onclick="mPreset('ground')">🌍 Ground Test</button>
771 </div>
772 </div>
773 <div class="card">
774 <div class="ctitle">Link Calculator</div>
775 <div class="frow">
776 <label class="flabel">Target Range (km)</label>
777 <div class="srow">
778 <input type="range" class="frange" id="lcRange" min="0.1" max="50" step="0.1" value="5" oninput="calcLink()">
779 <span class="sval" id="lcRangeV">5 km</span>
780 </div>
781 </div>
782 <div class="frow">
783 <label class="flabel">Antenna Gain (dBi)</label>
784 <div class="srow">
785 <input type="range" class="frange" id="lcGain" min="0" max="15" step="0.5" value="2" oninput="calcLink()">
786 <span class="sval" id="lcGainV">2 dBi</span>
787 </div>
788 </div>
789 <div style="padding:10px;background:rgba(0,0,0,.3);border-radius:5px;font-family:var(--mono);font-size:.75rem" id="lcResult">
790 Enter values above to calculate link budget
791 </div>
792 </div>
793</div>
794<!-- ========== CONFIG ========== -->
795<div id="pg-config" class="pg">
796 <div class="card">
797 <div class="ctitle">LoRa Parameters</div>
798 <div class="frow">
799 <label class="flabel">Frequency (MHz) — EU 863–870</label>
800 <div class="srow">
801 <input type="range" class="frange" id="cFreq" min="863" max="870" step="0.1" value="868" oninput="sl('cFreq','cFreqV','cFreqV2',' MHz')">
802 <span class="sval" id="cFreqV">868.0 MHz</span>
803 </div>
804 </div>
805 <div class="frow">
806 <label class="flabel">Bandwidth (kHz)</label>
807 <select class="fselect" id="cBW">
808 <option>7.8</option><option>10.4</option><option>15.6</option>
809 <option>20.8</option><option>31.25</option><option>41.7</option>
810 <option>62.5</option><option value="125" selected>125</option>
811 <option>250</option><option>500</option>
812 </select>
813 </div>
814 <div class="frow">
815 <label class="flabel">Spreading Factor (higher = longer range, slower)</label>
816 <div class="srow">
817 <input type="range" class="frange" id="cSF" min="5" max="12" value="9" oninput="slSF()">
818 <span class="sval" id="cSFV">SF9</span>
819 </div>
820 </div>
821 <div class="frow">
822 <label class="flabel">Coding Rate</label>
823 <div class="srow">
824 <input type="range" class="frange" id="cCR" min="5" max="8" value="7" oninput="slCR()">
825 <span class="sval" id="cCRV">4/7</span>
826 </div>
827 </div>
828 <div class="frow">
829 <label class="flabel">TX Power (dBm)</label>
830 <div class="srow">
831 <input type="range" class="frange" id="cPow" min="-9" max="22" value="14" oninput="slPow()">
832 <span class="sval" id="cPowV">14 dBm</span>
833 </div>
834 </div>
835 <div class="frow">
836 <label class="flabel">Sync Word (hex)</label>
837 <input type="text" class="finput" id="cSync" value="12" maxlength="2">
838 </div>
839 <button class="btn bg bfull" onclick="applyConfig()">▶ APPLY & SAVE TO FLASH</button>
840 </div>
841 <div class="card">
842 <div class="ctitle">Presets</div>
843 <div class="brow" style="margin-bottom:6px">
844 <button class="btn ba" onclick="preset(868,125,9,7,14,'12')">Default</button>
845 <button class="btn ba" onclick="preset(868,250,7,5,20,'12')">Fast Link</button>
846 </div>
847 <div class="brow" style="margin-bottom:6px">
848 <button class="btn ba" onclick="preset(868,62.5,12,8,14,'12')">Long Range</button>
849 <button class="btn bo" onclick="preset(145.825,125,9,5,14,'F0')">APRS/ISS</button>
850 </div>
851 <div class="brow">
852 <button class="btn bp" onclick="preset(868,125,7,5,17,'12')">Drone TM</button>
853 <button class="btn bp" onclick="preset(437.5,125,9,7,14,'12')">Sat 437MHz</button>
854 </div>
855 </div>
856 <div class="card">
857 <div class="ctitle">Estimated Performance</div>
858 <div class="kv" id="perfKV">
859 <span class="k">Airtime/packet</span><span class="v" id="pAirt">-- ms</span>
860 <span class="k">Est. sensitivity</span><span class="v" id="pSens">-- dBm</span>
861 <span class="k">Data rate</span><span class="v" id="pDR">-- bps</span>
862 </div>
863 </div>
864</div>
865<!-- ========== SPECTRUM ========== -->
866<div id="pg-spectrum" class="pg">
867 <div class="card">
868 <div class="ctitle">Spectrum Analyzer</div>
869 <div class="g2" style="margin-bottom:10px">
870 <div class="frow" style="margin:0">
871 <label class="flabel">Start (MHz)</label>
872 <input type="number" class="finput" id="spStart" value="860" min="800" max="1000">
873 </div>
874 <div class="frow" style="margin:0">
875 <label class="flabel">End (MHz)</label>
876 <input type="number" class="finput" id="spEnd" value="880" min="800" max="1000">
877 </div>
878 </div>
879 <button class="btn ba bfull" onclick="doSpectrum()">⟳ SCAN SPECTRUM</button>
880 <div id="spStatus" style="font-size:.68rem;color:var(--dim);text-align:center;margin-top:6px">Ready</div>
881 </div>
882 <div class="card">
883 <div class="ctitle">Result</div>
884 <div id="spBars" class="spwrap" style="margin-bottom:4px"></div>
885 <div style="display:flex;justify-content:space-between;font-size:.62rem;color:var(--dim)">
886 <span id="spLabelL">860</span><span>MHz</span><span id="spLabelR">880</span>
887 </div>
888 <canvas id="cSpectrum" height="55" style="margin-top:8px"></canvas>
889 </div>
890 <div class="card">
891 <div class="ctitle">Peak Channels</div>
892 <div id="spPeaks" style="font-family:var(--mono);font-size:.72rem;color:var(--dim)">Scan to see peaks</div>
893 </div>
894</div>
895<!-- ========== WIFI ========== -->
896<div id="pg-wifi" class="pg">
897 <div class="card">
898 <div class="ctitle">WiFi Networks Detected</div>
899 <div style="font-size:.68rem;color:var(--dim);margin-bottom:10px">
900 ⚠ WiFi scan requires exiting Web Mode. Run scan from device menu.
901 </div>
902 <div id="wifiList">
903 <div style="color:var(--dim);text-align:center;padding:14px;font-size:.8rem">No data — use WiFi Sniffer on device</div>
904 </div>
905 </div>
906</div>
907<!-- ========== INFO ========== -->
908<div id="pg-info" class="pg">
909 <div class="card">
910 <div class="ctitle">Device</div>
911 <div class="kv">
912 <span class="k">Firmware</span><span class="v" style="color:var(--accent2)">L.O.R.A. v11.0</span>
913 <span class="k">Platform</span><span class="v">ESP32-S3</span>
914 <span class="k">Radio</span><span class="v">SX1262</span>
915 <span class="k">Display</span><span class="v">SSD1306 128×64</span>
916 <span class="k">AP SSID</span><span class="v">LORA_AP</span>
917 <span class="k">AP Pass</span><span class="v">lora1234</span>
918 <span class="k">Web IP</span><span class="v">192.168.4.1</span>
919 </div>
920 </div>
921 <div class="card">
922 <div class="ctitle">REST API</div>
923 <table class="tbl">
924 <tr><th>Method</th><th>Endpoint</th><th>Description</th></tr>
925 <tr><td style="color:var(--accent2)">GET</td><td>/api/status</td><td>All stats + config</td></tr>
926 <tr><td style="color:var(--accent2)">GET</td><td>/api/log</td><td>Packet log JSON</td></tr>
927 <tr><td style="color:var(--accent2)">GET</td><td>/api/rssi_history</td><td>RSSI+SNR arrays</td></tr>
928 <tr><td style="color:var(--accent2)">GET</td><td>/api/spectrum</td><td>Spectrum data</td></tr>
929 <tr><td style="color:var(--accent2)">GET</td><td>/api/wifi_scan</td><td>WiFi SSID list</td></tr>
930 <tr><td style="color:var(--warn)">POST</td><td>/api/ping</td><td>Send PING</td></tr>
931 <tr><td style="color:var(--warn)">POST</td><td>/api/heartbeat</td><td>Send heartbeat</td></tr>
932 <tr><td style="color:var(--warn)">POST</td><td>/api/send?msg=</td><td>Send custom msg</td></tr>
933 <tr><td style="color:var(--warn)">POST</td><td>/api/set_lora</td><td>Set LoRa params</td></tr>
934 <tr><td style="color:var(--warn)">POST</td><td>/api/set_autotx</td><td>Auto-TX beacon</td></tr>
935 <tr><td style="color:var(--warn)">POST</td><td>/api/scan_spectrum</td><td>Run spectrum scan</td></tr>
936 <tr><td style="color:var(--warn)">POST</td><td>/api/clear</td><td>Clear all stats</td></tr>
937 </table>
938 </div>
939</div>
940<div id="toast" class="toast"></div>
941<script>
942// ===== STATE =====
943let lastLogLen = 0;
944let specData = null;
945let statusCache = {};
946// ===== TABS =====
947const tabNames = ['dash','comms','mission','config','spectrum','wifi','info'];
948function showTab(name) {
949 tabNames.forEach((t,i) => {
950 document.querySelectorAll('.tab')[i].classList.toggle('on', t===name);
951 document.getElementById('pg-'+t).classList.toggle('on', t===name);
952 });
953 if (name==='spectrum') updateSpectrum();
954 if (name==='wifi') loadWifi();
955 if (name==='mission') updateMission();
956}
957// ===== TOAST =====
958function toast(msg, col='var(--accent)') {
959 const el = document.getElementById('toast');
960 el.style.background = col; el.textContent = msg;
961 el.classList.add('on');
962 setTimeout(() => el.classList.remove('on'), 2000);
963}
964// ===== API =====
965async function api(ep, method='GET', body=null) {
966 try {
967 const opts = {method};
968 if (body) { opts.headers={'Content-Type':'application/x-www-form-urlencoded'}; opts.body=body; }
969 const r = await fetch('/api/'+ep, opts);
970 return await r.json();
971 } catch(e) { return null; }
972}
973// ===== COLORS =====
974function rc(r) {
975 if (r > -70) return 'var(--accent2)';
976 if (r > -85) return 'var(--accent)';
977 if (r > -100) return 'var(--warn)';
978 if (r > -115) return '#ff6600';
979 return 'var(--danger)';
980}
981function rl(r) {
982 if (r > -70) return 'EXCELLENT';
983 if (r > -85) return 'GOOD';
984 if (r > -100) return 'FAIR';
985 if (r > -115) return 'WEAK';
986 return 'VERY WEAK';
987}
988function rp(r) { return Math.max(0, Math.min(100, ((r+150)/120)*100)); }
989// ===== FORMAT =====
990function fmtTime(s) {
991 if (s < 60) return s+'s';
992 if (s < 3600) return Math.floor(s/60)+'m '+( s%60)+'s';
993 return Math.floor(s/3600)+'h '+Math.floor((s%3600)/60)+'m';
994}
995// ===== STATUS POLL =====
996async function pollStatus() {
997 const d = await api('status');
998 if (!d) { document.getElementById('cDot').style.background='var(--danger)'; return; }
999 statusCache = d;
1000 document.getElementById('cDot').style.background = 'var(--accent2)';
1001 document.getElementById('batHdr').textContent = d.batVolt.toFixed(2)+'V';
1002 const r = d.rssi, color = rc(r);
1003 const noSig = r < -900;
1004 document.getElementById('dRSSI').textContent = noSig ? '---' : r.toFixed(0);
1005 document.getElementById('dRSSI').style.color = color;
1006 document.getElementById('dSNR').textContent = noSig ? '---' : d.snr.toFixed(1);
1007 document.getElementById('dQlabel').textContent = noSig ? '---' : rl(r);
1008 document.getElementById('dQlabel').style.color = color;
1009 document.getElementById('dBar').style.width = (noSig ? 0 : rp(r)) + '%';
1010 document.getElementById('dBar').style.background = color;
1011 document.getElementById('dBest').textContent = d.bestRSSI < -900 ? '---' : d.bestRSSI.toFixed(0)+' dBm';
1012 document.getElementById('dAvg').textContent = d.avgRSSI === 0 ? '---' : d.avgRSSI.toFixed(0)+' dBm';
1013 document.getElementById('dRange').textContent = d.estRange < 0.01 ? '---' : d.estRange.toFixed(2)+' km';
1014 document.getElementById('dRX').textContent = d.rxCount;
1015 document.getElementById('dTX').textContent = d.txCount;
1016 document.getElementById('dERR').textContent = d.crcErrors;
1017 const bp = d.batPct;
1018 const bEl = document.getElementById('dBAT');
1019 bEl.textContent = bp; bEl.className = 'sv '+(bp<20?'r':bp<50?'o':'g');
1020 document.getElementById('dVolt').textContent = d.batVolt.toFixed(2)+' V';
1021 document.getElementById('dUp').textContent = fmtTime(d.uptime);
1022 document.getElementById('dFreq').textContent = d.freq.toFixed(1)+' MHz';
1023 document.getElementById('dSFBW').textContent = 'SF'+d.sf+' / '+d.bw+' kHz';
1024 document.getElementById('dAirt').textContent = d.airtime.toFixed(0)+' ms';
1025 document.getElementById('dPow').textContent = d.power+' dBm';
1026 document.getElementById('autoTxChk').checked = d.autoTx;
1027 document.getElementById('autoTxMsg').value = d.autoTxMsg;
1028 const iv = Math.round(d.autoTxInterval/1000);
1029 document.getElementById('autoTxInt').value = iv;
1030 document.getElementById('autoTxIntV').textContent = iv+'s';
1031 document.getElementById('cFreq').value = d.freq;
1032 document.getElementById('cFreqV').textContent = d.freq.toFixed(1)+' MHz';
1033 document.getElementById('cBW').value = d.bw;
1034 document.getElementById('cSF').value = d.sf;
1035 document.getElementById('cSFV').textContent = 'SF'+d.sf;
1036 document.getElementById('cCR').value = d.cr;
1037 document.getElementById('cCRV').textContent = '4/'+d.cr;
1038 document.getElementById('cPow').value = d.power;
1039 document.getElementById('cPowV').textContent = d.power+' dBm';
1040 document.getElementById('cSync').value = d.sw.toString(16).toUpperCase().padStart(2,'0');
1041 updatePerfEstimate(d.sf, d.bw, d.cr, d.power, d.freq);
1042}
1043async function pollLog() {
1044 const logs = await api('log');
1045 if (!logs) return;
1046 document.getElementById('logCount').textContent = '('+logs.length+')';
1047 if (logs.length === lastLogLen) return;
1048 lastLogLen = logs.length;
1049 const box = document.getElementById('logBox');
1050 if (logs.length === 0) {
1051 box.innerHTML = '<div style="color:var(--dim);text-align:center;padding:16px;font-size:.8rem">No packets</div>';
1052 return;
1053 }
1054 const rev = [...logs].reverse();
1055 box.innerHTML = rev.map((e,i) => {
1056 const isTX = e.d.startsWith('[TX]');
1057 const cls = isTX ? 'tx' : 'rx';
1058 const rssiTxt = e.r === 0 ? '' : `<span style="color:${rc(e.r)}">${e.r.toFixed(0)} dBm</span> SNR ${e.s.toFixed(1)} dB`;
1059 return `<div class="lentry ${cls}${i===0?' new':''}">
1060 <div class="ldata">${escH(e.d)}</div>
1061 <div class="lmeta">T+${fmtTime(e.t)} ${rssiTxt}</div>
1062 </div>`;
1063 }).join('');
1064 const lastRx = logs.filter(l=>!l.d.startsWith('[TX]')).pop();
1065 if (lastRx) parseTelemetry(lastRx.d);
1066}
1067async function pollHistory() {
1068 const h = await api('rssi_history');
1069 if (!h || !h.rssi || h.rssi.length < 2) return;
1070 drawLineChart('cRSSI', h.rssi, h.t, 'var(--accent)', -150, -30, 65);
1071 drawLineChart('cSNR', h.snr, h.t, 'var(--accent2)', -20, 20, 45);
1072}
1073function drawLineChart(id, data, times, color, yMin, yMax, canH) {
1074 const canvas = document.getElementById(id);
1075 if (!canvas) return;
1076 const W = canvas.offsetWidth || 300;
1077 canvas.width = W*2; canvas.height = canH*2;
1078 const ctx = canvas.getContext('2d');
1079 ctx.scale(2,2);
1080 const w=W, h=canH;
1081 ctx.clearRect(0,0,w,h);
1082 ctx.fillStyle='rgba(0,0,0,.25)'; ctx.fillRect(0,0,w,h);
1083 for(let i=0;i<=4;i++){
1084 const y=(i/4)*h;
1085 ctx.strokeStyle='rgba(255,255,255,.05)'; ctx.lineWidth=.5;
1086 ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();
1087 const val = (yMax - (i/4)*(yMax-yMin)).toFixed(0);
1088 ctx.fillStyle='rgba(255,255,255,.25)'; ctx.font='7px monospace';
1089 ctx.fillText(val, 2, y+6);
1090 }
1091 if(data.length < 2) return;
1092 const step = w/(data.length-1);
1093 const toY = v => h - ((v-yMin)/(yMax-yMin))*h*0.88 - 4;
1094 ctx.beginPath();
1095 data.forEach((v,i) => i===0 ? ctx.moveTo(0,toY(v)) : ctx.lineTo(i*step,toY(v)));
1096 ctx.lineTo(w,h); ctx.lineTo(0,h); ctx.closePath();
1097 const grad = ctx.createLinearGradient(0,0,0,h);
1098 grad.addColorStop(0,color.replace(')',',0.2)').replace('var(--accent)','rgba(255,140,0,0.2)').replace('var(--accent2)','rgba(255,102,0,0.2)'));
1099 grad.addColorStop(1,'transparent');
1100 ctx.fillStyle=grad; ctx.fill();
1101 ctx.beginPath();
1102 data.forEach((v,i) => i===0 ? ctx.moveTo(0,toY(v)) : ctx.lineTo(i*step,toY(v)));
1103 ctx.strokeStyle=color; ctx.lineWidth=1.5; ctx.stroke();
1104 const last = data[data.length-1];
1105 ctx.beginPath(); ctx.arc((data.length-1)*step, toY(last), 2.5, 0, Math.PI*2);
1106 ctx.fillStyle=color; ctx.fill();
1107}
1108async function updateSpectrum() {
1109 const d = await api('spectrum');
1110 if (!d || !d.values) return;
1111 specData = d;
1112 const bars = document.getElementById('spBars');
1113 bars.innerHTML = d.values.map(v => {
1114 const pct = Math.max(3,v);
1115 const col = v>70 ? 'var(--danger)' : v>40 ? 'var(--warn)' : 'var(--accent)';
1116 return `<div class="spbar" style="height:${pct}%;background:${col}"></div>`;
1117 }).join('');
1118 document.getElementById('spLabelL').textContent = d.freqStart;
1119 document.getElementById('spLabelR').textContent = d.freqEnd;
1120 drawSpectrumCanvas(d.values, d.freqStart, d.freqEnd);
1121 const step = (d.freqEnd - d.freqStart) / d.values.length;
1122 const peaks = d.values.map((v,i)=>({v,f:(d.freqStart+i*step).toFixed(2)}))
1123 .sort((a,b)=>b.v-a.v).slice(0,5);
1124 document.getElementById('spPeaks').innerHTML = peaks.map(p =>
1125 `<div style="color:${p.v>60?'var(--warn)':'var(--dim)'}"> ${p.f} MHz — ${(p.v-150).toFixed(0)} dBm</div>`
1126 ).join('');
1127}
1128function drawSpectrumCanvas(vals, fStart, fEnd) {
1129 const canvas = document.getElementById('cSpectrum');
1130 if (!canvas) return;
1131 const W = canvas.offsetWidth || 300;
1132 canvas.width = W*2; canvas.height = 110;
1133 const ctx = canvas.getContext('2d'); ctx.scale(2,2);
1134 const w=W, h=55;
1135 ctx.clearRect(0,0,w,h);
1136 ctx.fillStyle='rgba(0,0,0,.3)'; ctx.fillRect(0,0,w,h);
1137 const bw = w/vals.length;
1138 vals.forEach((v,i) => {
1139 const bh = (v/100)*h*0.9;
1140 const col = v>70?'#ff3358':v>40?'#ffaa00':'#ff8c00';
1141 const grad = ctx.createLinearGradient(0,h-bh,0,h);
1142 grad.addColorStop(0,col); grad.addColorStop(1,'rgba(0,0,0,.1)');
1143 ctx.fillStyle=grad; ctx.fillRect(i*bw, h-bh, bw-1, bh);
1144 });
1145}
1146async function doSpectrum() {
1147 const s = parseInt(document.getElementById('spStart').value)||860;
1148 const e = parseInt(document.getElementById('spEnd').value)||880;
1149 document.getElementById('spStatus').textContent = 'Scanning...';
1150 document.getElementById('spStatus').style.color = 'var(--warn)';
1151 toast('Spectrum scan started...', 'var(--warn)');
1152 await api('scan_spectrum','POST',`start=${s}&end=${e}`);
1153 setTimeout(async () => {
1154 await updateSpectrum();
1155 document.getElementById('spStatus').textContent = 'Scan complete';
1156 document.getElementById('spStatus').style.color = 'var(--accent2)';
1157 toast('Spectrum scan done!', 'var(--accent2)');
1158 }, 8000);
1159}
1160async function loadWifi() {
1161 const d = await api('wifi_scan');
1162 if (!d || d.length === 0) return;
1163 document.getElementById('wifiList').innerHTML = d.map(w =>
1164 `<div class="lentry rx" style="display:flex;justify-content:space-between;align-items:center">
1165 <div class="ldata">📶 ${escH(w.ssid)}</div>
1166 <div style="font-family:var(--mono);font-size:.72rem;color:${rc(w.rssi)}">${w.rssi} dBm</div>
1167 </div>`
1168 ).join('');
1169}
1170async function doPing() { const r=await api('ping','POST'); if(r) toast('⬛ PING sent!'); }
1171async function doHB() { const r=await api('heartbeat','POST'); if(r) toast('♡ Heartbeat sent!'); }
1172async function doSend() {
1173 const msg = document.getElementById('msgIn').value.trim();
1174 if(!msg) return;
1175 const r=await api('send','POST','msg='+encodeURIComponent(msg));
1176 if(r&&r.ok){ toast('✓ Sent!','var(--accent2)'); document.getElementById('msgIn').value=''; }
1177 else toast('✗ Error','var(--danger)');
1178}
1179async function doClear() { const r=await api('clear','POST'); if(r) toast('Cleared','var(--warn)'); lastLogLen=0; }
1180async function setAutoTx() {
1181 const en = document.getElementById('autoTxChk').checked ? '1':'0';
1182 const iv = document.getElementById('autoTxInt').value;
1183 const msg = document.getElementById('autoTxMsg').value;
1184 const r=await api('set_autotx','POST',`enabled=${en}&interval=${iv*1000}&msg=${encodeURIComponent(msg)}`);
1185 if(r) toast(en==='1'?'Auto-TX ON':'Auto-TX OFF', en==='1'?'var(--accent2)':'var(--dim)');
1186}
1187function sl(id,labelId,_,unit) {
1188 const v = parseFloat(document.getElementById(id).value);
1189 document.getElementById(labelId).textContent = v.toFixed(id==='cFreq'?1:0)+unit;
1190 updatePerfEstimate();
1191}
1192function slSF() { const v=document.getElementById('cSF').value; document.getElementById('cSFV').textContent='SF'+v; updatePerfEstimate(); }
1193function slCR() { const v=document.getElementById('cCR').value; document.getElementById('cCRV').textContent='4/'+v; updatePerfEstimate(); }
1194function slPow() { const v=document.getElementById('cPow').value; document.getElementById('cPowV').textContent=v+' dBm'; }
1195async function applyConfig() {
1196 const p = {
1197 freq: document.getElementById('cFreq').value,
1198 bw: document.getElementById('cBW').value,
1199 sf: document.getElementById('cSF').value,
1200 cr: document.getElementById('cCR').value,
1201 pw: document.getElementById('cPow').value,
1202 sw: document.getElementById('cSync').value
1203 };
1204 const r=await api('set_lora','POST',new URLSearchParams(p).toString());
1205 if(r&&r.ok) toast('✓ Config saved to flash!','var(--accent2)');
1206 else toast('✗ Error','var(--danger)');
1207}
1208function preset(f,bw,sf,cr,pw,sw) {
1209 document.getElementById('cFreq').value=f;
1210 document.getElementById('cFreqV').textContent=parseFloat(f).toFixed(1)+' MHz';
1211 document.getElementById('cBW').value=bw;
1212 document.getElementById('cSF').value=sf; document.getElementById('cSFV').textContent='SF'+sf;
1213 document.getElementById('cCR').value=cr; document.getElementById('cCRV').textContent='4/'+cr;
1214 document.getElementById('cPow').value=pw; document.getElementById('cPowV').textContent=pw+' dBm';
1215 document.getElementById('cSync').value=sw;
1216 applyConfig(); updatePerfEstimate(sf,bw,cr,pw,f);
1217}
1218function updatePerfEstimate(sf, bw, cr, pw, freq) {
1219 sf = sf || parseInt(document.getElementById('cSF').value);
1220 bw = bw || parseFloat(document.getElementById('cBW').value);
1221 cr = cr || parseInt(document.getElementById('cCR').value);
1222 pw = pw || parseInt(document.getElementById('cPow').value);
1223 freq= freq|| parseFloat(document.getElementById('cFreq').value);
1224 const bwHz = bw*1000;
1225 const ts = Math.pow(2,sf)/bwHz;
1226 const tpre = (8+4.25)*ts*1000;
1227 const nPay = 8 + Math.max(0,Math.ceil((8*20-4*sf+28+16)/(4*sf))*(cr));
1228 const airtime = (tpre + nPay*ts*1000).toFixed(0);
1229 const sens = (-174 + 10*Math.log10(bwHz) + 6 - (sf*2.5 - 10)).toFixed(0);
1230 const dr = (sf * bwHz / Math.pow(2,sf) * (4/(4+cr-4))).toFixed(0);
1231 if(document.getElementById('pAirt')) {
1232 document.getElementById('pAirt').textContent = airtime+' ms';
1233 document.getElementById('pSens').textContent = sens+' dBm';
1234 document.getElementById('pDR').textContent = dr+' bps';
1235 }
1236 if(document.getElementById('dAirt')) document.getElementById('dAirt').textContent = airtime+' ms';
1237}
1238function updateMission() {
1239 if(!statusCache.rssi) return;
1240 const d = statusCache;
1241 const lb = d.power - d.rssi;
1242 document.getElementById('mLB').textContent = lb.toFixed(1)+' dB';
1243 document.getElementById('mRange').textContent = d.estRange.toFixed(2)+' km';
1244 const total = d.rxCount + d.crcErrors;
1245 const crcRate = total > 0 ? (d.crcErrors/total*100).toFixed(1) : '0.0';
1246 document.getElementById('mCRC').textContent = crcRate+'%';
1247 const lq = Math.max(0, Math.min(100, ((d.rssi+150)/120*80) + (d.snr > 0 ? 20 : Math.max(0,20+d.snr*2))));
1248 document.getElementById('mLQ').textContent = lq.toFixed(0)+'%';
1249 const ppm = d.uptime > 0 ? (d.rxCount/(d.uptime/60)).toFixed(1) : '--';
1250 document.getElementById('mPPM').textContent = ppm;
1251}
1252function parseTelemetry(raw) {
1253 const fields = {};
1254 fields['Raw'] = raw;
1255 const parts = raw.split(/[,;|]+/);
1256 parts.forEach(p => {
1257 const kv = p.split(':');
1258 if (kv.length >= 2) fields[kv[0].trim()] = kv.slice(1).join(':').trim();
1259 });
1260 const container = document.getElementById('teleFields');
1261 if (!container) return;
1262 const pairs = Object.entries(fields);
1263 container.innerHTML = pairs.map(([k,v]) =>
1264 `<span class="k">${escH(k)}</span><span class="v">${escH(v)}</span>`
1265 ).join('');
1266}
1267function mPreset(type) {
1268 const presets = {
1269 drone: [868, 250, 7, 5, 20, '12'],
1270 iss: [145.825, 125, 9, 5, 14, 'F0'],
1271 sat: [437.5, 125, 9, 7, 14, '12'],
1272 ground: [868, 125, 9, 7, 14, '12']
1273 };
1274 if(presets[type]) preset(...presets[type]);
1275 showTab('config');
1276}
1277function calcLink() {
1278 const rangeKm = parseFloat(document.getElementById('lcRange').value);
1279 const gain = parseFloat(document.getElementById('lcGain').value);
1280 document.getElementById('lcRangeV').textContent = rangeKm+' km';
1281 document.getElementById('lcGainV').textContent = gain+' dBi';
1282 const freq = statusCache.freq || 868;
1283 const pw = statusCache.power || 14;
1284 const fspl = 20*Math.log10(rangeKm) + 20*Math.log10(freq) + 32.4;
1285 const rxPower = pw + gain*2 - fspl;
1286 const sf = statusCache.sf || 9;
1287 const bw = statusCache.bw || 125;
1288 const sens = -174 + 10*Math.log10(bw*1000) + 6 - (sf*2.5-10);
1289 const margin = rxPower - sens;
1290 const color = margin>10?'var(--accent2)':margin>0?'var(--warn)':'var(--danger)';
1291 document.getElementById('lcResult').innerHTML =
1292 `<span style="color:var(--dim)">Path Loss: </span><span style="color:var(--accent)">${fspl.toFixed(1)} dB</span><br>` +
1293 `<span style="color:var(--dim)">RX Power: </span><span style="color:${color}">${rxPower.toFixed(1)} dBm</span><br>` +
1294 `<span style="color:var(--dim)">Sensitivity: </span><span style="color:var(--accent)">${sens.toFixed(1)} dBm</span><br>` +
1295 `<span style="color:var(--dim)">Link Margin: </span><span style="color:${color};font-weight:bold">${margin.toFixed(1)} dB ${margin>10?'✓ OK':margin>0?'⚠ MARGINAL':'✗ FAIL'}</span>`;
1296}
1297document.getElementById('autoTxInt').addEventListener('input', function() {
1298 document.getElementById('autoTxIntV').textContent = this.value+'s';
1299});
1300document.getElementById('msgIn').addEventListener('keydown', e => { if(e.key==='Enter') doSend(); });
1301function escH(s) {
1302 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
1303}
1304async function poll() {
1305 await pollStatus();
1306 await pollLog();
1307 await pollHistory();
1308}
1309poll();
1310setInterval(poll, 1500);
1311updatePerfEstimate();
1312calcLink();
1313</script>
1314</body>
1315</html>
1316)rawliteral";
1317}
1318
1319// ==================== DISPLAY ====================
1320void drawHeader(const char* title) {
1321 display.setFont(u8g2_font_7x13B_tf);
1322 display.drawBox(0, 0, 128, 13);
1323 display.setDrawColor(0);
1324 display.drawStr(2, 11, title);
1325 display.setDrawColor(1);
1326}
1327
1328void drawMainMenu() {
1329 display.setFont(u8g2_font_6x12_tf);
1330 display.drawBox(0, 0, 128, 13);
1331 display.setDrawColor(0);
1332 display.drawStr(2, 11, "L.O.R.A.");
1333 display.setDrawColor(1);
1334 int startIdx = max(0, min(menuIndex - 2, menuSize - 5));
1335 for (int i = startIdx; i < min(menuSize, startIdx + 5); i++) {
1336 int y = 26 + (i - startIdx) * 10;
1337 if (i == menuIndex) {
1338 display.drawBox(0, y-9, 128, 10);
1339 display.setDrawColor(0);
1340 display.drawStr(2, y, menuItems[i]);
1341 display.setDrawColor(1);
1342 } else {
1343 display.drawStr(2, y, menuItems[i]);
1344 }
1345 }
1346 if (menuSize > 5) {
1347 int sbH=50, sbY=13, barH=max(6,sbH/menuSize*5);
1348 int barY = sbY + (menuIndex*(sbH-barH))/(menuSize-1);
1349 display.drawFrame(125,sbY,3,sbH); display.drawBox(125,barY,3,barH);
1350 }
1351 display.sendBuffer();
1352}
1353
1354void drawLoRaScanner() {
1355 drawHeader("LORA RX");
1356 if (rxCount == 0) {
1357 display.setFont(u8g2_font_7x13_tf);
1358 display.drawStr(12, 38, "Listening...");
1359 display.sendBuffer(); return;
1360 }
1361 int barW = rssiToBar(lastRSSI);
1362 display.drawFrame(0,14,128,8); display.drawBox(0,14,barW,8);
1363 display.setFont(u8g2_font_7x13_tf);
1364 char line[32];
1365 snprintf(line, sizeof(line), "RSSI %4.0f dBm", lastRSSI); display.drawStr(0, 38, line);
1366 snprintf(line, sizeof(line), " SNR %+4.1f dB", lastSNR); display.drawStr(0, 52, line);
1367 display.setFont(u8g2_font_6x12_tf);
1368 snprintf(line, sizeof(line), "%s RX:%d TX:%d", rssiLabel(lastRSSI), rxCount, txCount);
1369 display.drawStr(0, 63, line);
1370 display.sendBuffer();
1371}
1372
1373void drawCommsLog() {
1374 drawHeader("COMMS LOG");
1375 int total = logFull ? LOG_LEN : logIdx;
1376 if (total == 0) { display.setFont(u8g2_font_7x13_tf); display.drawStr(10, 36, "No packets"); display.sendBuffer(); return; }
1377 display.setFont(u8g2_font_6x12_tf);
1378 for (int i = 0; i < min(4, total); i++) {
1379 int idx = (logIdx - 1 - i + LOG_LEN) % LOG_LEN;
1380 char line[22];
1381 snprintf(line, sizeof(line), "%3ds %s", (int)msgTime[idx], msgLog[idx].substring(0,14).c_str());
1382 display.drawStr(0, 25 + i*10, line);
1383 }
1384 display.sendBuffer();
1385}
1386
1387void drawDashboard() {
1388 drawHeader("DASHBOARD");
1389 display.setFont(u8g2_font_7x13_tf);
1390 char buf[24];
1391 snprintf(buf, sizeof(buf), "Bat %.2fV %d%%", batteryVoltage, batteryPercent); display.drawStr(0, 25, buf);
1392 snprintf(buf, sizeof(buf), "RX:%-4d TX:%-4d", rxCount, txCount); display.drawStr(0, 39, buf);
1393 snprintf(buf, sizeof(buf), "Best %.0f dBm", bestRSSI); display.drawStr(0, 53, buf);
1394 display.setFont(u8g2_font_6x12_tf);
1395 snprintf(buf, sizeof(buf), "Up:%s", fmtUptime(millis()/1000).c_str()); display.drawStr(0, 63, buf);
1396 display.sendBuffer();
1397}
1398
1399String fmtUptime(uint32_t s) {
1400 if (s < 60) return String(s) + "s";
1401 if (s < 3600) return String(s/60) + "m" + String(s%60) + "s";
1402 return String(s/3600) + "h" + String((s%3600)/60) + "m";
1403}
1404
1405void drawRSSIGraph() {
1406 drawHeader("RSSI GRAPH");
1407 int total = histFull ? HISTORY_LEN : histIdx;
1408 if (total == 0) { display.setFont(u8g2_font_7x13_tf); display.drawStr(20,40,"No data"); display.sendBuffer(); return; }
1409 int barW = max(1, 128/HISTORY_LEN);
1410 for (int i = 0; i < total; i++) {
1411 int idx = histFull ? (histIdx + i) % HISTORY_LEN : i;
1412 int h = rssiToBar(rssiHistory[idx]) * 48 / 124;
1413 if (h < 1) h = 1;
1414 display.drawBox(i*barW, 63-h, max(1,barW-1), h);
1415 }
1416 display.setFont(u8g2_font_5x7_tf);
1417 char buf[16]; snprintf(buf, sizeof(buf), "n=%d", total); display.drawStr(2, 22, buf);
1418 display.sendBuffer();
1419}
1420
1421void drawWiFiSniffer() {
1422 drawHeader("WiFi SCAN");
1423 display.setFont(u8g2_font_7x13_tf);
1424 if (!wifiScanDone) { display.drawStr(10, 36, "Scanning..."); }
1425 else if (ssidCount==0){ display.drawStr(5, 36, "None found"); }
1426 else {
1427 display.setFont(u8g2_font_6x12_tf);
1428 for (int i = 0; i < min(5, ssidCount); i++)
1429 display.drawStr(0, 24+i*9, ssidList[i].substring(0,20).c_str());
1430 }
1431 display.sendBuffer();
1432}
1433
1434void drawAPRS_ISS() {
1435 drawHeader("APRS / ISS");
1436 display.setFont(u8g2_font_7x13_tf);
1437 display.drawStr(0, 28, "145.825 MHz");
1438 char buf[24]; snprintf(buf, sizeof(buf), "RX: %d pkts", rxCount);
1439 display.drawStr(0, 42, buf);
1440 display.setFont(u8g2_font_6x12_tf);
1441 display.drawStr(0, 55, "SEL=Exit PING=TX");
1442 display.sendBuffer();
1443}
1444
1445void drawSpectrumAnalyzer() {
1446 drawHeader("SPECTRUM");
1447 if (spectrumScanning) { display.setFont(u8g2_font_7x13_tf); display.drawStr(10,38,"Scanning..."); display.sendBuffer(); return; }
1448 int barW = max(1, 128/spectrumSteps);
1449 for (int i = 0; i < spectrumSteps; i++) {
1450 int h = (spectrumValues[i] * 46) / 100;
1451 if (h < 1) h = 1;
1452 display.drawBox(i*barW, 62-h, max(1,barW-1), h);
1453 }
1454 display.setFont(u8g2_font_5x7_tf);
1455 display.drawStr(0, 63, "860"); display.drawStr(96, 63, "880MHz");
1456 display.drawStr(18, 15, "PING=Scan SEL=Back");
1457 display.sendBuffer();
1458}
1459
1460void drawWebControl() {
1461 drawHeader("WEB CTRL");
1462 display.setFont(u8g2_font_7x13_tf);
1463 if (webModeActive) {
1464 display.drawStr(0, 26, AP_SSID);
1465 display.drawStr(0, 40, "PW: " AP_PASS);
1466 display.setFont(u8g2_font_6x12_tf);
1467 display.drawStr(0, 53, AP_IP);
1468 display.drawStr(0, 63, "SEL=Stop PING=Ping");
1469 } else {
1470 display.drawStr(0, 34, "Press SEL");
1471 display.drawStr(0, 50, "to start AP");
1472 }
1473 display.sendBuffer();
1474}
1475
1476// ==================== LoRa Settings Menu ====================
1477int settingsIndex = 0;
1478const char* settingsItems[] = {"Frequency","Bandwidth","SF","Coding Rate","Sync Word","Power","Save & Exit"};
1479const int settingsSize = 7;
1480
1481void drawLoRaSettings() {
1482 drawHeader("LORA CFG");
1483 display.setFont(u8g2_font_6x12_tf);
1484 for (int i = 0; i < settingsSize; i++) {
1485 int y = 24 + i*6;
1486 if (i == settingsIndex) {
1487 display.drawBox(0, y-5, 128, 6);
1488 display.setDrawColor(0); display.drawStr(2, y, settingsItems[i]); display.setDrawColor(1);
1489 } else {
1490 display.drawStr(2, y, settingsItems[i]);
1491 }
1492 }
1493 display.setFont(u8g2_font_5x7_tf);
1494 char val[32]; snprintf(val, sizeof(val), "%.1fMHz SF%d BW%.0f", loraConfig.frequency, loraConfig.spreadingFactor, loraConfig.bandwidth);
1495 display.drawStr(0, 63, val);
1496 display.sendBuffer();
1497}
1498
1499void editLoRaSetting() {
1500 bool editing = true;
1501 int newValue = 0;
1502 switch(settingsIndex) {
1503 case 0: newValue=(int)(loraConfig.frequency*10); break;
1504 case 1: newValue=(int)loraConfig.bandwidth; break;
1505 case 2: newValue=loraConfig.spreadingFactor; break;
1506 case 3: newValue=loraConfig.codingRate; break;
1507 case 4: newValue=loraConfig.syncWord; break;
1508 case 5: newValue=loraConfig.power; break;
1509 }
1510 while (editing) {
1511 display.clearBuffer();
1512 drawHeader("EDIT");
1513 display.setFont(u8g2_font_7x13_tf);
1514 display.drawStr(0, 26, settingsItems[settingsIndex]);
1515 char valStr[24];
1516 switch(settingsIndex) {
1517 case 0: snprintf(valStr,sizeof(valStr),"%.1f MHz",newValue/10.0); break;
1518 case 1: snprintf(valStr,sizeof(valStr),"%.0f kHz",(float)newValue); break;
1519 case 2: snprintf(valStr,sizeof(valStr),"SF%d",newValue); break;
1520 case 3: snprintf(valStr,sizeof(valStr),"4/%d",newValue); break;
1521 case 4: snprintf(valStr,sizeof(valStr),"0x%02X",newValue); break;
1522 case 5: snprintf(valStr,sizeof(valStr),"%d dBm",newValue); break;
1523 }
1524 display.setFont(u8g2_font_9x15B_tf); display.drawStr(0, 48, valStr);
1525 display.setFont(u8g2_font_6x12_tf); display.drawStr(0, 63, "UP/DN:Chg SEL:OK PING:X");
1526 display.sendBuffer();
1527 int key = getKey();
1528 if (key == 0) {
1529 switch(settingsIndex) {
1530 case 0: newValue++; break;
1531 case 1: if(newValue<500){if(newValue<125)newValue=125;else if(newValue<250)newValue=250;else newValue=500;} break;
1532 case 2: if(newValue<12)newValue++; break;
1533 case 3: if(newValue<8)newValue++; break;
1534 case 4: if(newValue<255)newValue++; break;
1535 case 5: if(newValue<22)newValue++; break;
1536 }
1537 } else if (key==1) {
1538 switch(settingsIndex) {
1539 case 0: newValue--; break;
1540 case 1: if(newValue>62){if(newValue>250)newValue=250;else if(newValue>125)newValue=125;else newValue=62;} break;
1541 case 2: if(newValue>5)newValue--; break;
1542 case 3: if(newValue>5)newValue--; break;
1543 case 4: if(newValue>0)newValue--; break;
1544 case 5: if(newValue>-9)newValue--; break;
1545 }
1546 } else if (key==2) {
1547 switch(settingsIndex) {
1548 case 0: loraConfig.frequency=newValue/10.0; break;
1549 case 1: loraConfig.bandwidth=newValue; break;
1550 case 2: loraConfig.spreadingFactor=newValue; break;
1551 case 3: loraConfig.codingRate=newValue; break;
1552 case 4: loraConfig.syncWord=newValue; break;
1553 case 5: loraConfig.power=newValue; break;
1554 }
1555 editing = false;
1556 } else if (key==3) editing = false;
1557 delay(50);
1558 }
1559}
1560
1561// ==================== SETUP ====================
1562void setup() {
1563 pinMode(LED_PIN, OUTPUT);
1564 pinMode(BTN_UP, INPUT_PULLUP);
1565 pinMode(BTN_DOWN, INPUT_PULLUP);
1566 pinMode(BTN_SEL, INPUT_PULLUP);
1567 pinMode(BTN_PING, INPUT_PULLUP);
1568 pinMode(BAT_PIN, INPUT);
1569 attachInterrupt(digitalPinToInterrupt(BTN_UP), isr_up, FALLING);
1570 attachInterrupt(digitalPinToInterrupt(BTN_DOWN), isr_down, FALLING);
1571 attachInterrupt(digitalPinToInterrupt(BTN_SEL), isr_sel, FALLING);
1572 attachInterrupt(digitalPinToInterrupt(BTN_PING), isr_ping, FALLING);
1573 Serial.begin(115200);
1574 Wire.begin(OLED_SDA, OLED_SCL);
1575 display.begin();
1576 analogReadResolution(13);
1577 EEPROM.begin(EEPROM_SIZE);
1578 loadLoRaSettings();
1579 WiFi.mode(WIFI_OFF);
1580 esp_wifi_stop();
1581 SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS);
1582 int state = radio.begin(loraConfig.frequency, loraConfig.bandwidth,
1583 loraConfig.spreadingFactor, loraConfig.codingRate,
1584 loraConfig.syncWord, loraConfig.power, 8);
1585 if (state != RADIOLIB_ERR_NONE) {
1586 display.clearBuffer();
1587 display.setFont(u8g2_font_7x13B_tf); display.drawStr(0,30,"LoRa ERROR!");
1588 display.sendBuffer(); while(1);
1589 }
1590 radio.setCurrentLimit(140.0);
1591 radio.setDio1Action(setFlag);
1592 display.clearBuffer();
1593 display.setFont(u8g2_font_9x15B_tf);
1594 display.drawStr(5, 22, "L.O.R.A.");
1595 display.setFont(u8g2_font_7x13_tf);
1596 display.drawStr(5, 40, "LoRa Obsessive");
1597 display.drawStr(5, 56, "Radio Annoyer");
1598 display.sendBuffer();
1599 delay(2000);
1600 startRx();
1601 currentScreen = MAIN_MENU;
1602 startTime = millis();
1603}
1604
1605// ==================== LOOP ====================
1606void loop() {
1607 if (webModeActive) server.handleClient();
1608 if (autoTxMode && (millis() - lastAutoTx > autoTxInterval)) {
1609 lastAutoTx = millis();
1610 sendCustom(autoTxMsg);
1611 }
1612 int key = getKey();
1613 if (key >= 0) {
1614 digitalWrite(LED_PIN, HIGH); delay(10); digitalWrite(LED_PIN, LOW);
1615 if (currentScreen == MAIN_MENU) {
1616 if (key==0) menuIndex = (menuIndex - 1 + menuSize) % menuSize;
1617 else if (key==1) menuIndex = (menuIndex + 1) % menuSize;
1618 else if (key==2) {
1619 switch(menuIndex) {
1620 case 0: currentScreen=LORA_SCANNER; stopAPRSScanner(); break;
1621 case 1: currentScreen=COMMS_LOG; stopAPRSScanner(); break;
1622 case 2: currentScreen=DASHBOARD; stopAPRSScanner(); break;
1623 case 3: currentScreen=RSSI_GRAPH; stopAPRSScanner(); break;
1624 case 4: currentScreen=WIFI_SNIFFER; performStealthWiFiScan(); break;
1625 case 5: currentScreen=APRS_ISS; startAPRSScanner(); break;
1626 case 6: currentScreen=LORA_SETTINGS; stopAPRSScanner(); break;
1627 case 7: currentScreen=SPECTRUM_ANALYZER; stopAPRSScanner(); break;
1628 case 8: currentScreen=WEB_CONTROL; stopAPRSScanner(); break;
1629 }
1630 } else if (key==3) sendPing();
1631 }
1632 else if (currentScreen==WEB_CONTROL) {
1633 if (key==2) { if(!webModeActive) startWebMode(); else { stopWebMode(); currentScreen=MAIN_MENU; } }
1634 else if (key==3 && webModeActive) sendPing();
1635 else if ((key==0||key==1) && !webModeActive) currentScreen=MAIN_MENU;
1636 }
1637 else if (currentScreen==LORA_SETTINGS) {
1638 if (key==0) settingsIndex=(settingsIndex-1+settingsSize)%settingsSize;
1639 else if (key==1) settingsIndex=(settingsIndex+1)%settingsSize;
1640 else if (key==2) {
1641 if (settingsIndex==settingsSize-1) { saveLoRaSettings(); applyLoRaSettings(); currentScreen=MAIN_MENU; }
1642 else editLoRaSetting();
1643 } else if (key==3) currentScreen=MAIN_MENU;
1644 }
1645 else if (currentScreen==SPECTRUM_ANALYZER) {
1646 if (key==2) currentScreen=MAIN_MENU;
1647 else if (key==3) performSpectrumScan();
1648 }
1649 else {
1650 if (key==2) { currentScreen=MAIN_MENU; stopAPRSScanner(); }
1651 if (key==1) sendHeartbeat();
1652 if (key==3) sendPing();
1653 }
1654 }
1655 if (millis() - lastBatteryRead > 5000) {
1656 batteryVoltage = getBatteryVoltage();
1657 batteryPercent = getBatteryPercent(batteryVoltage);
1658 lastBatteryRead = millis();
1659 }
1660 if (!autoTxMode && millis()-lastTxTime > 4000 &&
1661 (currentScreen==LORA_SCANNER||currentScreen==COMMS_LOG) && !aprsModeActive) {
1662 lastTxTime = millis();
1663 sendHeartbeat();
1664 }
1665 if (rxFlag) {
1666 rxFlag = false;
1667 digitalWrite(LED_PIN, HIGH);
1668 String data;
1669 int state = radio.readData(data);
1670 if (state == RADIOLIB_ERR_NONE) {
1671 rxCount++;
1672 lastRSSI = radio.getRSSI();
1673 lastSNR = radio.getSNR();
1674 lastData = data;
1675 if (lastRSSI > bestRSSI) bestRSSI = lastRSSI;
1676 addHistory(lastRSSI, lastSNR);
1677 addToLog(data, lastRSSI, lastSNR);
1678 } else if (state == RADIOLIB_ERR_CRC_MISMATCH) {
1679 crcErrors++;
1680 }
1681 digitalWrite(LED_PIN, LOW);
1682 startRx();
1683 }
1684 static uint32_t lastDraw = 0;
1685 uint32_t interval = webModeActive ? 400 : 180;
1686 if (millis() - lastDraw > interval) {
1687 lastDraw = millis();
1688 display.clearBuffer();
1689 switch(currentScreen) {
1690 case MAIN_MENU: drawMainMenu(); break;
1691 case LORA_SCANNER: drawLoRaScanner(); break;
1692 case COMMS_LOG: drawCommsLog(); break;
1693 case DASHBOARD: drawDashboard(); break;
1694 case RSSI_GRAPH: drawRSSIGraph(); break;
1695 case WIFI_SNIFFER: drawWiFiSniffer(); break;
1696 case APRS_ISS: drawAPRS_ISS(); break;
1697 case LORA_SETTINGS: drawLoRaSettings(); break;
1698 case SPECTRUM_ANALYZER: drawSpectrumAnalyzer(); break;
1699 case WEB_CONTROL: drawWebControl(); break;
1700 }
1701 display.sendBuffer();
1702 }
1703}
1704
1705void startRx() { rxFlag = false; radio.startReceive(); }
1706void IRAM_ATTR setFlag() { rxFlag = true; }
Note: Content and images are from: https://projecthub.arduino.cc/, with some modifications.
If you want it removed due to copyright reasons, please leave a comment. Thank you.
I want to share this article more widely so that everyone knows about Arduino and your project.