QC

LoRa Obsessive Radio Annoying

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> &nbsp;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)} &nbsp; ${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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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.

Easy remote control

Remotely control an LED with ease. Devices and components Arduino Nano 33 BLE with headers Box 525 Resistors precision 1% - 17 values Breadb...