/*
============================================================
REM-Relay v1.0.0 — Remote Relay Controller
4 Relay · 1 Coax · FLEX Radio Integration - by m0pkz - Kes.
============================================================
Required Libraries (install via Arduino IDE Library Manager):
- ArduinoJson by Benoit Blanchon (v6.x)
Built-in ESP32 Arduino Core libraries used:
- WiFi, WebServer, WiFiUdp, ESPmDNS, Preferences, ArduinoOTA
============================================================
WIRING:
RELAY 1 → GPIO 26
RELAY 2 → GPIO 27
RELAY 3 → GPIO 25
RELAY 4 → GPIO 33
(Relays are ACTIVE LOW – connect NO/COM/NC accordingly)
RESET WiFi: Hold BOOT button (GPIO 0) for 5 seconds
============================================================
*/
// ─────────────────────────────────────────────────────────
// INCLUDES
// ─────────────────────────────────────────────────────────
#include <WiFi.h>
#include <WiFiUdp.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Preferences.h>
#include <ArduinoOTA.h>
#include <Update.h> // HTTP OTA firmware update
#include <ArduinoJson.h>
// ─── OLED Display (0.96" SSD1306 128x64 I2C) ─────────────────
// Install via Library Manager:
// "Adafruit SSD1306" by Adafruit
// "Adafruit GFX Library" by Adafruit
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
// ─────────────────────────────────────────────────────────
// PIN DEFINITIONS
// ─────────────────────────────────────────────────────────
#define RELAY1_PIN 26
#define RELAY2_PIN 27
#define RELAY3_PIN 25
#define RELAY4_PIN 33
#define BOOT_BTN_PIN 0 // GPIO0 = BOOT button for WiFi reset
// ─────────────────────────────────────────────────────────
// CONSTANTS
// ─────────────────────────────────────────────────────────
#define FLEX_PORT 4992
#define AP_SSID "REM-Relay"
#define AP_PASS "12345678"
#define MDNS_NAME "rem-relay"
#define FW_VERSION "1.0.0"
#define MAX_LOG 50
#define MAX_DEBUG 30
#define RECONNECT_DELAY 8000UL // ms between reconnect attempts
#define DISCOVERY_TIMEOUT 12000UL // ms for UDP discovery timeout
#define RELAY_SWITCH_DELAY 200 // ms settle time before switching relay
// ─── OLED ─────────────────────────────────────────────────────
#define OLED_SDA 21 // I2C SDA
#define OLED_SCL 22 // I2C SCL
#define OLED_WIDTH 128
#define OLED_HEIGHT 64
#define OLED_ADDR 0x3C // Common SSD1306 address (try 0x3D if blank)
Adafruit_SSD1306 oled(OLED_WIDTH, OLED_HEIGHT, &Wire, -1);
bool oledOK = false;
unsigned long lastOledUpdate = 0;
// Forward declarations
void oledInit();
void oledUpdate();
// ─────────────────────────────────────────────────────────
// STRUCTS
// ─────────────────────────────────────────────────────────
struct RadioState {
String ip = "";
String model = "FLEX Radio";
String frequency = "0.000000";
String band = "--";
String mode = "--";
String txState = "RECEIVE";
float rfPower = 0.0f;
float swr = 1.0f;
bool connected = false;
unsigned long lastUpdate = 0;
};
struct BandEntry {
const char* band;
float fStart;
float fEnd;
uint8_t relay; // 1–4
};
// ─────────────────────────────────────────────────────────
// BAND MAP (default assignments)
// ─────────────────────────────────────────────────────────
#define BAND_COUNT 11
BandEntry bandMap[BAND_COUNT] = {
{ "160m", 1.800f, 2.000f, 1 },
{ "80m", 3.500f, 4.000f, 2 },
{ "60m", 5.330f, 5.405f, 2 },
{ "40m", 7.000f, 7.300f, 2 },
{ "30m", 10.100f, 10.150f, 3 },
{ "20m", 14.000f, 14.350f, 1 },
{ "17m", 18.068f, 18.168f, 3 },
{ "15m", 21.000f, 21.450f, 3 },
{ "12m", 24.890f, 24.990f, 4 },
{ "10m", 28.000f, 29.700f, 4 },
{ "6m", 50.000f, 54.000f, 4 }
};
// ─────────────────────────────────────────────────────────
// GLOBAL STATE
// ─────────────────────────────────────────────────────────
WebServer server(80);
WiFiClient flexClient;
WiFiUDP udpDiscover;
Preferences prefs;
RadioState radio;
bool relayState[4] = { false, false, false, false };
String antName[4] = { "Hexbeam", "Vertical", "Loop", "6m Beam" };
bool autoMode = true;
bool txLockEnabled = true;
bool autoReconnect = true;
bool useManualIP = false;
String flexManualIP = "";
int bandSwitchDelay = RELAY_SWITCH_DELAY;
bool apMode = false;
// Logs
String eventLog[MAX_LOG];
int logHead = 0, logCount = 0;
String debugLog[MAX_DEBUG];
int dbgHead = 0, dbgCount = 0;
String lastDebugRX = "--";
String lastError = "None";
int reconnectCount = 0;
// Timers
unsigned long lastReconnectAttempt = 0;
unsigned long discoveryStart = 0;
bool discovering = false;
bool atuTuning = false; // ATU tune cycle active
unsigned long bootMs = 0;
// Boot-button WiFi reset
unsigned long btnHoldStart = 0;
bool btnHeld = false;
// ─────────────────────────────────────────────────────────
// LOGGING HELPERS
// ─────────────────────────────────────────────────────────
String timestamp() {
unsigned long s = millis() / 1000;
char buf[10];
snprintf(buf, sizeof(buf), "%02lu:%02lu:%02lu",
s / 3600, (s % 3600) / 60, s % 60);
return String(buf);
}
void addLog(const String& msg) {
String entry = timestamp() + " " + msg;
eventLog[logHead] = entry;
logHead = (logHead + 1) % MAX_LOG;
if (logCount < MAX_LOG) logCount++;
Serial.println("[LOG] " + entry);
}
void addDebug(const String& msg) {
String entry = timestamp() + " " + msg;
debugLog[dbgHead] = entry;
dbgHead = (dbgHead + 1) % MAX_DEBUG;
if (dbgCount < MAX_DEBUG) dbgCount++;
Serial.println("[DBG] " + entry);
}
// Return logs as JSON array (most recent first)
String logsToJSON(String* arr, int head, int count, int maxItems) {
String out = "[";
int n = min(count, maxItems);
// Walk backwards from (head-1)
for (int i = 0; i < n; i++) {
int idx = ((head - 1 - i) % count + count) % count;
if (i > 0) out += ",";
// Escape quotes
String entry = arr[idx];
entry.replace("\"", "\\\"");
out += "\"" + entry + "\"";
}
out += "]";
return out;
}
// ─────────────────────────────────────────────────────────
// RELAY CONTROL
// ─────────────────────────────────────────────────────────
const int relayPins[4] = { RELAY1_PIN, RELAY2_PIN, RELAY3_PIN, RELAY4_PIN };
void setRelayHW(int idx, bool on) {
// idx 0–3; relays are ACTIVE LOW
relayState[idx] = on;
digitalWrite(relayPins[idx], on ? LOW : HIGH);
}
// Force a single relay on, all others off (TX-safe)
bool activateRelay(int relayNum, bool applyDelay = true) {
if (relayNum < 1 || relayNum > 4) return false;
if (txLockEnabled && radio.txState == "TRANSMITTING") {
addLog("TX LOCK: relay switch blocked (transmitting)");
return false;
}
if (atuTuning) {
addLog("ATU LOCK: relay switch blocked (ATU tuning)");
return false;
}
// Skip blocking delay for manual web UI calls (applyDelay=false)
// Keep delay only for auto band-change to let ATU/coax settle
if (applyDelay && bandSwitchDelay > 0) delay(bandSwitchDelay);
for (int i = 0; i < 4; i++) setRelayHW(i, false);
setRelayHW(relayNum - 1, true);
addLog("RELAY " + String(relayNum) + " ON → " + antName[relayNum - 1]);
oledUpdate();
return true;
}
// Individual toggle (manual page)
bool setRelay(int relayNum, bool on) {
if (relayNum < 1 || relayNum > 4) return false;
if (on && txLockEnabled && radio.txState == "TRANSMITTING") {
addLog("TX LOCK: blocked relay " + String(relayNum));
return false;
}
// If turning on, turn off all others first
if (on) {
for (int i = 0; i < 4; i++) {
if (i != relayNum - 1) setRelayHW(i, false);
}
}
setRelayHW(relayNum - 1, on);
addLog("RELAY " + String(relayNum) + (on ? " ON" : " OFF") + " → " + antName[relayNum - 1]);
return true;
}
void allRelaysOff() {
for (int i = 0; i < 4; i++) setRelayHW(i, false);
addLog("All relays OFF");
}
// ─────────────────────────────────────────────────────────
// BAND DETECTION / AUTO SWITCH
// ─────────────────────────────────────────────────────────
int getBandRelay(const String& band) {
for (int i = 0; i < BAND_COUNT; i++) {
if (band == bandMap[i].band) return bandMap[i].relay;
}
return 0;
}
void handleBandChange(const String& newBand) {
if (!autoMode) return;
int r = getBandRelay(newBand);
if (r > 0) {
addLog("Auto: " + newBand + " → RELAY " + String(r) + " (" + antName[r - 1] + ")");
activateRelay(r);
} else {
addLog("No relay assigned for band " + newBand);
}
}
// ─────────────────────────────────────────────────────────
// FLEX-6300 SMARTSDR TCP PARSER
// ─────────────────────────────────────────────────────────
String extractValue(const String& line, const String& key) {
String search = key + "=";
int idx = 0;
while (true) {
idx = line.indexOf(search, idx);
if (idx < 0) return "";
// Ensure it is a whole-word match: preceded by space, | or start of string
if (idx == 0 || line.charAt(idx - 1) == ' ' || line.charAt(idx - 1) == '|') break;
idx++; // skip partial match, keep searching
}
int start = idx + search.length();
int end = line.indexOf(' ', start);
if (end < 0) end = line.indexOf('\r', start);
if (end < 0) end = line.indexOf('\n', start);
if (end < 0) end = line.length();
return line.substring(start, end);
}
void parseFlexMessage(String msg) {
msg.trim();
if (msg.length() == 0) return;
// Store raw for debug (truncate long lines)
lastDebugRX = msg.substring(0, min((unsigned int)msg.length(), 100u));
addDebug("RX: " + lastDebugRX);
// ── Version handshake + model detection ──────────────────
if (msg.startsWith("V")) {
addLog("SmartSDR API: " + msg);
// Extract model from version line e.g. "V FLEX-6300 ..."
if (msg.indexOf("FLEX-6300") >= 0) radio.model = "FLEX-6300";
else if (msg.indexOf("FLEX-6400") >= 0) radio.model = "FLEX-6400";
else if (msg.indexOf("FLEX-6500") >= 0) radio.model = "FLEX-6500";
else if (msg.indexOf("FLEX-6600") >= 0) radio.model = "FLEX-6600";
else if (msg.indexOf("FLEX-6700") >= 0) radio.model = "FLEX-6700";
else if (msg.indexOf("FLEX-8600") >= 0) radio.model = "FLEX-8600";
else if (msg.indexOf("FLEX-8400") >= 0) radio.model = "FLEX-8400";
else if (msg.indexOf("FLEX-6300M") >= 0) radio.model = "FLEX-6300M";
else if (msg.indexOf("FLEX-6400M") >= 0) radio.model = "FLEX-6400M";
return;
}
// ── Response to our commands ───────────────────────────
if (msg.startsWith("R")) {
int p = msg.indexOf('|');
if (p > 0) {
String code = msg.substring(p + 1, msg.indexOf('|', p + 1));
if (code != "0") {
lastError = "CMD error: " + msg;
addDebug("CMD Error: " + msg);
}
}
return;
}
// ── Status messages ────────────────────────────────────
if (msg.startsWith("S")) {
int pipeIdx = msg.indexOf('|');
if (pipeIdx < 0) return;
String payload = msg.substring(pipeIdx + 1);
// Radio model from status message e.g. "S...|radio model=FLEX-6300 ..."
if (payload.startsWith("radio ")) {
String mdl = extractValue(payload, "model");
if (mdl.length() > 0) {
radio.model = mdl;
Serial.println("[FLEX] Radio model: " + mdl);
addLog("Radio model: " + mdl);
}
}
// Slice status (frequency / band / mode)
if (payload.startsWith("slice ")) {
// Extract slice index (slice 0 / slice 1 …)
// Only track slice 0 (main VFO)
String sliceIdx = "";
int s1 = payload.indexOf(' ');
int s2 = payload.indexOf(' ', s1 + 1);
if (s1 >= 0 && s2 >= 0) sliceIdx = payload.substring(s1 + 1, s2);
// RF_frequency – SmartSDR reports in MHz with 6 decimal places
String freqStr = extractValue(payload, "RF_frequency");
if (freqStr.length() == 0) freqStr = extractValue(payload, "frequency");
if (freqStr.length() > 0) {
double f = freqStr.toDouble();
// Store as full Hz integer for exact display e.g. 14250555
unsigned long freqHz = (unsigned long)(f * 1000000.0 + 0.5);
// Format as XXX.XXX.XXX Hz
unsigned long mhz = freqHz / 1000000UL;
unsigned long khz = (freqHz % 1000000UL) / 1000UL;
unsigned long hz = freqHz % 1000UL;
char buf[20];
snprintf(buf, sizeof(buf), "%lu.%03lu.%03lu", mhz, khz, hz);
radio.frequency = String(buf);
radio.lastUpdate = millis();
// Derive band from frequency immediately as fallback
if (radio.band == "--" || radio.band.length() == 0) {
String bDerived = "";
if (f >= 1.8 && f <= 2.0) bDerived = "160m";
else if (f >= 3.5 && f <= 4.0) bDerived = "80m";
else if (f >= 5.33 && f <= 5.405) bDerived = "60m";
else if (f >= 7.0 && f <= 7.3) bDerived = "40m";
else if (f >= 10.1 && f <= 10.15) bDerived = "30m";
else if (f >= 14.0 && f <= 14.35) bDerived = "20m";
else if (f >= 18.068&& f <= 18.168)bDerived = "17m";
else if (f >= 21.0 && f <= 21.45) bDerived = "15m";
else if (f >= 24.89 && f <= 24.99) bDerived = "12m";
else if (f >= 28.0 && f <= 29.7) bDerived = "10m";
else if (f >= 50.0 && f <= 54.0) bDerived = "6m";
if (bDerived.length() > 0) {
radio.band = bDerived;
Serial.println("[FLEX] Band derived: " + bDerived);
handleBandChange(bDerived);
}
}
}
// Band – from explicit key, then derive from frequency as fallback
String bandStr = extractValue(payload, "band");
// Normalise numeric SmartSDR band IDs → "20m", "15m" etc.
if (bandStr.length() > 0 && bandStr.indexOf('m') < 0 && bandStr.indexOf('M') < 0) {
bandStr = bandStr + "m";
}
// If band still empty or invalid, derive from frequency
if ((bandStr.length() == 0 || bandStr == "--" || bandStr == "m") && radio.frequency.length() > 3) {
// Parse MHz from stored formatted freq "21.225.000" → 21.225 MHz
String fStr = radio.frequency;
fStr.replace(".", ""); // remove dots → "21225000"
double fHz = fStr.toDouble();
double fMHz = fHz / 1000000.0;
if (fMHz >= 1.8 && fMHz <= 2.0) bandStr = "160m";
else if (fMHz >= 3.5 && fMHz <= 4.0) bandStr = "80m";
else if (fMHz >= 5.33 && fMHz <= 5.405) bandStr = "60m";
else if (fMHz >= 7.0 && fMHz <= 7.3) bandStr = "40m";
else if (fMHz >= 10.1 && fMHz <= 10.15) bandStr = "30m";
else if (fMHz >= 14.0 && fMHz <= 14.35) bandStr = "20m";
else if (fMHz >= 18.068&& fMHz <= 18.168)bandStr = "17m";
else if (fMHz >= 21.0 && fMHz <= 21.45) bandStr = "15m";
else if (fMHz >= 24.89 && fMHz <= 24.99) bandStr = "12m";
else if (fMHz >= 28.0 && fMHz <= 29.7) bandStr = "10m";
else if (fMHz >= 50.0 && fMHz <= 54.0) bandStr = "6m";
if (bandStr.length() > 0) addDebug("Band derived from freq: " + bandStr);
}
if (bandStr.length() > 0 && bandStr != "m" && bandStr != radio.band) {
String prev = radio.band;
radio.band = bandStr;
Serial.println("[FLEX] Band: " + prev + " → " + bandStr);
addLog("Band: " + bandStr);
handleBandChange(bandStr);
oledUpdate();
}
// Mode
String modeStr = extractValue(payload, "mode");
if (modeStr.length() > 0) radio.mode = modeStr;
}
// Interlock – TX state
if (payload.startsWith("interlock") || payload.indexOf("interlock") >= 0) {
String stateStr = extractValue(payload, "state");
if (stateStr.length() > 0) {
// SmartSDR interlock states: PTT_REQUESTED, TRANSMITTING, UNKEY_REQUESTED, RECEIVE, …
bool wasTX = (radio.txState != "RECEIVE");
radio.txState = stateStr;
if (stateStr == "TRANSMITTING" && !wasTX) {
addLog("TX started – relay lock engaged");
Serial.println("[FLEX] TX ACTIVE");
} else if (stateStr == "RECEIVE" && wasTX) {
addLog("TX ended – relay lock released");
Serial.println("[FLEX] TX ENDED");
}
}
String pwrStr = extractValue(payload, "rf_power");
if (pwrStr.length() > 0) radio.rfPower = pwrStr.toFloat();
}
// SWR
String swrStr = extractValue(payload, "swr");
if (swrStr.length() > 0) radio.swr = swrStr.toFloat();
// ATU status parsing
if (payload.startsWith("atu") || payload.indexOf(" atu ") >= 0) {
String atuSt = extractValue(payload, "status");
if (atuSt.length() > 0) {
bool wasTuning = atuTuning;
atuTuning = (atuSt == "TUNING" || atuSt == "TUNED");
if (atuTuning && !wasTuning) {
addLog("ATU: Tuning started – relay switch locked");
addDebug("ATU status: " + atuSt);
Serial.println("[ATU] Tuning started – relay locked");
} else if (!atuTuning && wasTuning) {
addLog("ATU: Tune complete (" + atuSt + ") – relay unlocked");
Serial.println("[ATU] Tune complete: " + atuSt);
}
}
}
}
// ── Handle (HN) messages ─ heart / misc ──────────────
// (ignore gracefully)
}
// ─────────────────────────────────────────────────────────
// FLEX-6300 TCP CONNECTION
// ─────────────────────────────────────────────────────────
void connectToFlex(const String& ip) {
if (flexClient.connected()) {
flexClient.stop();
delay(100);
}
Serial.println("[FLEX] Connecting to " + ip + ":" + String(FLEX_PORT));
addLog("Connecting to FLEX @ " + ip);
if (flexClient.connect(ip.c_str(), FLEX_PORT, 4000)) {
radio.connected = true;
radio.ip = ip;
reconnectCount++; // increment only on successful connect
addLog("Connected to FLEX-6300 (" + ip + ")");
Serial.println("[FLEX] Connected! Sending subscriptions…");
delay(300);
flexClient.println("C1|sub slice all");
flexClient.println("C2|sub interlock");
flexClient.println("C3|sub transmit");
flexClient.println("C4|sub atu");
flexClient.println("C5|sub radio all"); // get model info
Serial.println("[FLEX] Subscriptions sent");
addDebug("Subscribed: slice, interlock, transmit, atu");
} else {
radio.connected = false;
lastError = "TCP connect failed to " + ip;
addLog("Connection FAILED to FLEX @ " + ip);
addDebug("TCP connect error → " + ip + ":" + String(FLEX_PORT));
Serial.println("[FLEX] Connection FAILED");
}
}
// ─────────────────────────────────────────────────────────
// UDP DISCOVERY
// ─────────────────────────────────────────────────────────
void startDiscovery() {
discovering = true;
discoveryStart = millis();
udpDiscover.begin(FLEX_PORT);
addLog("Starting UDP discovery (port " + String(FLEX_PORT) + ")…");
addDebug("UDP socket opened for discovery");
Serial.println("[DISC] Discovery started");
}
void stopDiscovery() {
discovering = false;
udpDiscover.stop();
Serial.println("[DISC] Discovery stopped");
}
void checkDiscovery() {
if (!discovering) return;
int pktSize = udpDiscover.parsePacket();
if (pktSize > 0) {
char buf[512];
int len = udpDiscover.read(buf, min(pktSize, (int)sizeof(buf) - 1));
buf[len] = '\0';
String senderIP = udpDiscover.remoteIP().toString();
uint16_t senderPort = udpDiscover.remotePort();
// Print bytes as hex – SmartSDR uses binary framing, not plain ASCII
String hexDump = "";
for (int i = 0; i < min(len, 12); i++) {
char hx[4]; sprintf(hx, "%02X ", (uint8_t)buf[i]);
hexDump += hx;
}
Serial.println("[DISC] Pkt from " + senderIP + ":" + String(senderPort)
+ " len=" + String(len) + " hex=[" + hexDump + "]");
addDebug("Pkt " + senderIP + " len=" + String(len) + " hex=" + hexDump);
// SmartSDR sends binary-framed UDP on port 4992 – content is NOT plain
// ASCII "FLEX". Any packet arriving on this port IS the radio.
// Use the sender IP directly – no text matching needed.
addLog("FLEX-6300 discovered @ " + senderIP);
Serial.println("[DISC] Found FLEX @ " + senderIP);
stopDiscovery();
connectToFlex(senderIP);
return;
}
if (millis() - discoveryStart > DISCOVERY_TIMEOUT) {
stopDiscovery();
lastError = "Discovery timeout – no FLEX-6300 found";
addLog("Discovery timeout – no FLEX found");
addDebug("Discovery timeout after " + String(DISCOVERY_TIMEOUT/1000) + "s");
Serial.println("[DISC] Timeout – use manual IP in Settings > FLEX Connection");
}
}
// ─────────────────────────────────────────────────────────
// CONFIG (NVS via Preferences)
// ─────────────────────────────────────────────────────────
void saveConfig() {
prefs.begin("antctrl", false);
prefs.putBool("autoMode", autoMode);
prefs.putBool("txLock", txLockEnabled);
prefs.putBool("autoRecon", autoReconnect);
prefs.putBool("useManIP", useManualIP);
prefs.putString("manIP", flexManualIP);
prefs.putString("flexIP", radio.ip);
prefs.putInt("swDelay", bandSwitchDelay);
for (int i = 0; i < 4; i++) prefs.putString(("an" + String(i)).c_str(), antName[i]);
for (int i = 0; i < BAND_COUNT; i++) prefs.putInt(("br" + String(i)).c_str(), bandMap[i].relay);
prefs.end();
addLog("Configuration saved to NVS");
Serial.println("[CFG] Saved");
}
void loadConfig() {
prefs.begin("antctrl", true);
autoMode = prefs.getBool("autoMode", true);
txLockEnabled = prefs.getBool("txLock", true);
autoReconnect = prefs.getBool("autoRecon", true);
useManualIP = prefs.getBool("useManIP", false);
flexManualIP = prefs.getString("manIP", "");
radio.ip = prefs.getString("flexIP", "");
bandSwitchDelay = prefs.getInt("swDelay", RELAY_SWITCH_DELAY);
for (int i = 0; i < 4; i++) antName[i] = prefs.getString(("an" + String(i)).c_str(), antName[i]);
for (int i = 0; i < BAND_COUNT; i++) bandMap[i].relay = prefs.getInt(("br" + String(i)).c_str(), bandMap[i].relay);
prefs.end();
Serial.println("[CFG] Loaded from NVS");
}
// ─────────────────────────────────────────────────────────
// WIFI CREDENTIALS (separate NVS namespace)
// ─────────────────────────────────────────────────────────
String wifiSSID = "", wifiPass = "";
bool loadWiFiCreds() {
prefs.begin("wifi", true);
wifiSSID = prefs.getString("ssid", "");
wifiPass = prefs.getString("pass", "");
prefs.end();
return wifiSSID.length() > 0;
}
void saveWiFiCreds(const String& ssid, const String& pass) {
prefs.begin("wifi", false);
prefs.putString("ssid", ssid);
prefs.putString("pass", pass);
prefs.end();
wifiSSID = ssid; wifiPass = pass;
Serial.println("[WIFI] Credentials saved: " + ssid);
}
void clearWiFiCreds() {
prefs.begin("wifi", false);
prefs.remove("ssid"); prefs.remove("pass");
prefs.end();
wifiSSID = ""; wifiPass = "";
Serial.println("[WIFI] Credentials cleared");
}
// ─────────────────────────────────────────────────────────
// AP PORTAL HTML (served from PROGMEM)
// ─────────────────────────────────────────────────────────
const char AP_PAGE[] PROGMEM = R"====(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>REM-Relay – WiFi Setup</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0d1117;color:#e6edf3;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
.wrap{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:36px;width:100%;max-width:460px}
.ico{text-align:center;font-size:52px;margin-bottom:10px}
h1{text-align:center;font-size:22px;color:#58a6ff;margin-bottom:4px}
.sub{text-align:center;color:#8b949e;font-size:13px;margin-bottom:24px}
h2{font-size:15px;color:#f0f6fc;margin-bottom:10px}
.net-list{max-height:260px;overflow-y:auto;border:1px solid #30363d;border-radius:8px;margin-bottom:16px}
.net-item{display:flex;align-items:center;padding:11px 14px;border-bottom:1px solid #21262d;cursor:pointer;transition:background .15s}
.net-item:last-child{border-bottom:none}
.net-item:hover{background:#1c2128}
.net-item input{margin-right:12px;accent-color:#58a6ff;width:15px;height:15px;flex-shrink:0}
.net-name{flex:1;font-size:13px;font-weight:500}
.net-sig{font-size:11px;color:#8b949e;margin-left:8px}
.net-lock{font-size:12px;margin-left:6px}
.scan-msg{text-align:center;padding:22px;color:#8b949e;font-size:13px}
.spin{display:inline-block;width:18px;height:18px;border:2px solid #30363d;border-top-color:#58a6ff;
border-radius:50%;animation:spin .7s linear infinite;vertical-align:middle;margin-right:8px}
@keyframes spin{to{transform:rotate(360deg)}}
.sel-net{background:#1c2128;border:1px solid #58a6ff;border-radius:8px;padding:10px 14px;
font-size:13px;margin-bottom:4px}
.sel-net span{color:#58a6ff;font-weight:700}
label{display:block;font-size:12px;color:#8b949e;margin-bottom:5px;margin-top:14px}
input[type=text],input[type=password]{width:100%;padding:9px 13px;background:#0d1117;border:1px solid #30363d;
border-radius:6px;color:#e6edf3;font-size:14px;outline:none}
input:focus{border-color:#58a6ff}
.btn{display:block;width:100%;padding:11px;border:none;border-radius:6px;font-size:14px;
font-weight:600;cursor:pointer;margin-top:14px;transition:background .2s}
.btn-blue{background:#1f6feb;color:#fff}.btn-blue:hover{background:#388bfd}
.btn-green{background:#238636;color:#fff}.btn-green:hover{background:#2ea043}
#status{margin-top:14px;padding:11px 14px;border-radius:6px;font-size:13px;display:none;line-height:1.5}
.s-info{background:#1f3a5f;border:1px solid #1f6feb;color:#58a6ff;display:block}
.s-ok{background:#1a3a2a;border:1px solid #238636;color:#3fb950;display:block}
.s-err{background:#3a1a1a;border:1px solid #da3633;color:#f85149;display:block}
</style>
</head>
<body>
<div class="wrap">
<div class="ico">📡</div>
<h1>REM-Relay</h1>
<p class="sub">Remote Relay Controller — First Time Setup</p>
<h2>Select your WiFi Network</h2>
<div id="netList" class="net-list">
<div class="scan-msg"><span class="spin"></span>Scanning…</div>
</div>
<button class="btn btn-blue" onclick="scan()">🔄 Rescan Networks</button>
<div id="pwSection" style="display:none">
<div class="sel-net" id="selDisplay">Selected: <span id="selName"></span></div>
<label>WiFi Password</label>
<input type="password" id="pwInput" placeholder="Enter password…" autocomplete="off">
<button class="btn btn-green" onclick="connect()">✅ Connect & Save</button>
</div>
<div id="status"></div>
</div>
<script>
var sel='';
function sigBars(r){return r>-50?'▁▃▅█':r>-65?'▁▃▅░':r>-75?'▁▃░░':'▁░░░'}
function scan(){
document.getElementById('netList').innerHTML='<div class="scan-msg"><span class="spin"></span>Scanning…</div>';
document.getElementById('pwSection').style.display='none';
fetch('/scan').then(r=>r.json()).then(nets=>{
if(!nets||!nets.length){document.getElementById('netList').innerHTML='<div class="scan-msg">No networks found – try rescanning.</div>';return}
var h='';
nets.forEach(function(n){
h+='<div class="net-item" onclick="pick(\''+n.ssid.replace(/\\/g,'\\\\').replace(/'/g,"\\'")+'\')">'
+'<input type="radio" name="net">'
+'<div class="net-name">'+n.ssid+'</div>'
+'<div class="net-sig">'+sigBars(n.rssi)+' '+n.rssi+'dBm</div>'
+'<div class="net-lock">'+(n.enc?'🔒':'🔓')+'</div></div>';
});
document.getElementById('netList').innerHTML=h;
}).catch(function(){document.getElementById('netList').innerHTML='<div class="scan-msg">Scan failed – try again.</div>'});
}
function pick(ssid){
sel=ssid;
document.getElementById('selName').textContent=ssid;
document.getElementById('pwSection').style.display='block';
document.getElementById('pwInput').focus();
document.getElementById('pwSection').scrollIntoView({behavior:'smooth'});
}
function connect(){
if(!sel){showSt('Please select a network.','s-err');return}
var pw=document.getElementById('pwInput').value;
showSt('Connecting to <strong>'+sel+'</strong>…','s-info');
fetch('/connect',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'ssid='+encodeURIComponent(sel)+'&pass='+encodeURIComponent(pw)})
.then(function(r){return r.json()})
.then(function(d){
if(d.success){
showSt('✅ Connected!<br>IP Address: <strong>'+d.ip+'</strong><br>Redirecting in 4 seconds…','s-ok');
setTimeout(function(){window.location.href='http://'+d.ip},4000);
}else{showSt('❌ Failed: '+(d.error||'wrong password or timeout'),'s-err')}
}).catch(function(){showSt('⚠️ Timed out – board may be rebooting. Try http://'+sel,'s-err')});
}
function showSt(msg,cls){
var e=document.getElementById('status');
e.innerHTML=msg;e.className=cls;
}
window.onload=scan;
</script>
</body>
</html>
)====";
// ─────────────────────────────────────────────────────────
// MAIN DASHBOARD HTML (served from PROGMEM)
// ─────────────────────────────────────────────────────────
const char MAIN_PAGE[] PROGMEM = R"====(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>REM-Relay</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0d1117;color:#e6edf3;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
display:flex;min-height:100vh;font-size:13px}
/* ── Sidebar ── */
.sb{width:195px;background:#161b22;border-right:1px solid #30363d;display:flex;flex-direction:column;flex-shrink:0}
.sb-logo{padding:18px 14px;border-bottom:1px solid #30363d}
.sb-logo h2{font-size:13px;color:#58a6ff;font-weight:700}
.sb-logo p{font-size:11px;color:#8b949e;margin-top:2px}
.nav{display:flex;align-items:center;padding:9px 14px;cursor:pointer;border-left:3px solid transparent;
color:#8b949e;transition:all .15s;user-select:none}
.nav:hover{background:#1c2128;color:#e6edf3}
.nav.on{background:#1c2128;color:#58a6ff;border-left-color:#58a6ff}
.nav-ic{margin-right:9px;font-size:14px}
/* ── Main ── */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}
.topbar{background:#161b22;border-bottom:1px solid #30363d;padding:9px 18px;
display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.tb-title{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}
.tb-right{display:flex;align-items:center;gap:14px;font-size:12px}
.badge{padding:2px 9px;border-radius:20px;font-size:11px;font-weight:600}
.bg{background:#1a3a2a;color:#3fb950;border:1px solid #238636}
.br{background:#3a1a1a;color:#f85149;border:1px solid #da3633}
.by{background:#3a2a1a;color:#e3b341;border:1px solid #9e6a03}
.bb{background:#1f3a5f;color:#58a6ff;border:1px solid #1f6feb}
.btn-sm{background:#21262d;border:1px solid #30363d;color:#e6edf3;padding:4px 12px;
border-radius:6px;cursor:pointer;font-size:12px}
.btn-sm:hover{background:#30363d}
/* ── Content ── */
.content{flex:1;overflow-y:auto;padding:16px}
.page{display:none}.page.on{display:block}
/* ── Grid ── */
.g2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.g3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}
.g4{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:10px}
.mb14{margin-bottom:14px}
/* ── Card ── */
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px}
.ct{font-size:11px;font-weight:700;letter-spacing:.7px;color:#58a6ff;text-transform:uppercase;margin-bottom:12px}
/* ── Radio Status ── */
.rs{display:grid;grid-template-columns:1fr 1fr;gap:2px 14px}
.rr{display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid #21262d;font-size:12px}
.rr:last-child{border-bottom:none}
.rk{color:#8b949e}.rv{color:#e6edf3;font-weight:500}
.rv.g{color:#3fb950}.rv.r{color:#f85149}.rv.y{color:#e3b341;font-size:15px;font-weight:700}.rv.b{color:#58a6ff;font-size:15px;font-weight:700}
/* ── Active antenna ── */
.aa{text-align:center;padding:8px 0}
.aa-band{font-size:11px;color:#8b949e;letter-spacing:1px;text-transform:uppercase;margin-bottom:3px}
.aa-ic{font-size:44px;margin:6px 0}
.aa-name{font-size:20px;font-weight:700;margin-bottom:4px}
.aa-relay{font-size:13px;color:#3fb950;font-weight:600}
/* ── Relay status ── */
.ri{display:flex;align-items:center;padding:7px 0;border-bottom:1px solid #21262d}
.ri:last-child{border-bottom:none}
.rnum{width:22px;height:22px;border-radius:50%;background:#21262d;display:flex;align-items:center;
justify-content:center;font-size:11px;font-weight:700;margin-right:10px;flex-shrink:0}
.rname{flex:1}
/* ── Toggle ── */
.tog{position:relative;display:inline-block;width:38px;height:21px;cursor:pointer;flex-shrink:0}
.tog input{display:none}
.tsl{position:absolute;top:0;left:0;right:0;bottom:0;background:#21262d;border-radius:21px;transition:.25s}
.tsl:before{content:'';position:absolute;width:15px;height:15px;left:3px;top:3px;
background:#8b949e;border-radius:50%;transition:.25s}
.tog input:checked+.tsl{background:#238636}
.tog input:checked+.tsl:before{transform:translateX(17px);background:#fff}
/* ── Band table ── */
.bt{width:100%;border-collapse:collapse;font-size:12px}
.bt th{padding:6px 8px;text-align:left;font-size:11px;color:#8b949e;
text-transform:uppercase;border-bottom:1px solid #30363d}
.bt td{padding:6px 8px;border-bottom:1px solid #21262d}
.bt tr:last-child td{border-bottom:none}
.bt tr.ar td{background:#1c2128}
.rbadge{display:inline-block;padding:2px 7px;border-radius:4px;font-size:11px;font-weight:700}
.rsel{background:#21262d;border:1px solid #30363d;color:#e6edf3;padding:3px 7px;border-radius:4px;font-size:12px}
/* ── Manual relay buttons ── */
.mb{background:#21262d;border:2px solid #30363d;border-radius:8px;padding:12px 6px;
text-align:center;cursor:pointer;transition:all .2s}
.mb:hover{border-color:#58a6ff}
.mb.on{border-color:#238636;background:#1a3a2a}
.mb .mn{font-size:10px;color:#8b949e;margin-bottom:4px}
.mb .mna{font-size:12px;font-weight:600}
.amode{width:100%;padding:13px;border:none;border-radius:8px;font-size:14px;font-weight:700;cursor:pointer}
.amode.auto{background:#238636;color:#fff}
.amode.man{background:#9e6a03;color:#fff}
.amode-sub{text-align:center;font-size:11px;color:#8b949e;margin-top:5px}
/* ── Inputs ── */
input[type=text],input[type=password],input[type=number]{background:#0d1117;border:1px solid #30363d;
border-radius:6px;color:#e6edf3;padding:7px 11px;font-size:13px;outline:none}
input:focus{border-color:#58a6ff}
select{background:#21262d;border:1px solid #30363d;color:#e6edf3;padding:6px 10px;border-radius:6px;font-size:13px}
/* ── Buttons ── */
.bp{background:#238636;border:none;border-radius:6px;color:#fff;padding:7px 15px;font-size:13px;cursor:pointer;font-weight:600}
.bp:hover{background:#2ea043}
.bs{background:#21262d;border:1px solid #30363d;border-radius:6px;color:#e6edf3;padding:7px 15px;font-size:13px;cursor:pointer}
.bs:hover{background:#30363d}
.bd{background:#da3633;border:none;border-radius:6px;color:#fff;padding:7px 15px;font-size:13px;cursor:pointer}
.bd:hover{background:#b91c1c}
/* ── Log ── */
.log-wrap{font-family:'Courier New',monospace;font-size:12px;max-height:200px;overflow-y:auto}
.le{padding:2px 0;border-bottom:1px solid #21262d;display:flex;gap:8px}
.lt{color:#3fb950;flex-shrink:0}
.lm{color:#8b949e}
.lm.w{color:#e3b341}.lm.e{color:#f85149}.lm.i{color:#58a6ff}
/* ── System rows ── */
.sr{display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid #21262d;font-size:12px}
.sr:last-child{border-bottom:none}
.sk{color:#8b949e}.sv{color:#e6edf3}
/* ── Quick actions ── */
.qa{display:grid;grid-template-columns:1fr 1fr;gap:7px}
.qb{background:#21262d;border:1px solid #30363d;border-radius:6px;padding:8px 10px;font-size:12px;
color:#e6edf3;cursor:pointer;display:flex;align-items:center;gap:6px;transition:background .2s}
.qb:hover{background:#30363d}
.qb.d{border-color:#da3633;color:#f85149}.qb.d:hover{background:#3a1a1a}
/* ── Settings ── */
.set-row{display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid #21262d}
.set-row:last-child{border-bottom:none}
.set-lbl{font-size:13px}.set-desc{font-size:11px;color:#8b949e;margin-top:2px}
/* ── Debug ── */
.dbg-str{background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:11px;
font-family:'Courier New',monospace;font-size:11px;max-height:220px;overflow-y:auto;color:#3fb950}
.dts{color:#58a6ff;margin-right:7px}
/* ── FLEX image ── */
.radio-img{width:100%;max-height:72px;object-fit:contain;border-radius:4px;margin:6px 0;opacity:.85;filter:drop-shadow(0 0 6px #1f6feb55)}
/* ── Scrollbar ── */
::-webkit-scrollbar{width:5px;height:5px}
::-webkit-scrollbar-track{background:#0d1117}
::-webkit-scrollbar-thumb{background:#30363d;border-radius:3px}
</style>
</head>
<body>
<!-- ═══ SIDEBAR ═══ -->
<div class="sb">
<div class="sb-logo"><h2>🔌 REM-Relay</h2><p id="sb-model">FLEX Radio Controller</p></div>
<div class="nav on" onclick="pg('dashboard',this)"><span class="nav-ic">🏠</span>Dashboard</div>
<div class="nav" onclick="pg('bandsetup',this)"><span class="nav-ic">📻</span>Band Setup</div>
<div class="nav" onclick="pg('antennas',this)"><span class="nav-ic">📡</span>Antennas</div>
<div class="nav" onclick="pg('settings',this)"><span class="nav-ic">⚙️</span>Settings</div>
<div class="nav" onclick="pg('logs',this)"><span class="nav-ic">📋</span>Logs</div>
<div class="nav" onclick="pg('debug',this)"><span class="nav-ic">🐛</span>Debug</div>
<div class="nav" onclick="pg('system',this)"><span class="nav-ic">💻</span>System</div>
<div class="nav" onclick="pg('about',this)"><span class="nav-ic">ℹ️</span>About</div>
</div>
<!-- ═══ MAIN ═══ -->
<div class="main">
<!-- TOP BAR -->
<div class="topbar">
<div class="tb-title"><span>📡</span><span>REM-Relay</span></div>
<div class="tb-right">
<span style="color:#8b949e">Uptime: <span id="tb-up">0:00:00</span></span>
<span id="tb-flex" class="badge br">FLEX Disconnected</span>
<span id="tb-atu" class="badge by" style="display:none">ATU TUNING</span>
<span id="tb-wifi" class="badge bg">WiFi OK</span>
<span id="tb-rssi-bar" title="WiFi Signal" style="display:inline-flex;align-items:flex-end;gap:1px;height:14px;cursor:default">
<span id="rssi-b1" style="width:3px;height:4px;background:#30363d;border-radius:1px"></span>
<span id="rssi-b2" style="width:3px;height:7px;background:#30363d;border-radius:1px"></span>
<span id="rssi-b3" style="width:3px;height:10px;background:#30363d;border-radius:1px"></span>
<span id="rssi-b4" style="width:3px;height:14px;background:#30363d;border-radius:1px"></span>
</span>
<button class="btn-sm" onclick="confirmReboot()">Reboot</button>
</div>
</div>
<!-- CONTENT -->
<div class="content">
<!-- ══ DASHBOARD ══ -->
<div id="pg-dashboard" class="page on">
<div class="g2 mb14">
<!-- Radio Status -->
<div class="card">
<div class="ct">📻 Radio Status <span style="font-size:10px;color:#3fb950;font-weight:400">(LIVE)</span></div>
<div style="display:flex;align-items:center;gap:12px;background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:8px 12px;margin-bottom:10px">
<svg width="56" height="36" viewBox="0 0 56 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- FLEX-6300 side profile -->
<!-- Main chassis -->
<rect x="1" y="5" width="54" height="26" rx="3" fill="#1c2128" stroke="#388bfd" stroke-width="1.5"/>
<!-- Screen/waterfall area -->
<rect x="3" y="8" width="26" height="15" rx="2" fill="#0a1628" stroke="#1f6feb" stroke-width="1"/>
<!-- Waterfall lines -->
<line x1="5" y1="12" x2="27" y2="12" stroke="#1f6feb" stroke-width="0.8" opacity="0.6"/>
<line x1="5" y1="15" x2="27" y2="15" stroke="#58a6ff" stroke-width="0.8" opacity="0.8"/>
<line x1="5" y1="18" x2="27" y2="18" stroke="#1f6feb" stroke-width="0.8" opacity="0.5"/>
<line x1="5" y1="11" x2="22" y2="11" stroke="#3fb950" stroke-width="1" opacity="0.9"/>
<!-- Freq display strip -->
<rect x="3" y="24" width="26" height="5" rx="1" fill="#0d1117"/>
<rect x="5" y="25" width="18" height="3" rx="1" fill="#e3b341" opacity="0.3"/>
<!-- VFO knob -->
<circle cx="43" cy="16" r="7" fill="#21262d" stroke="#388bfd" stroke-width="1.2"/>
<circle cx="43" cy="16" r="4" fill="#161b22"/>
<circle cx="43" cy="10.5" r="1.2" fill="#58a6ff"/>
<!-- Small knobs -->
<circle cx="35" cy="11" r="2.5" fill="#21262d" stroke="#30363d" stroke-width="1"/>
<circle cx="35" cy="21" r="2.5" fill="#21262d" stroke="#30363d" stroke-width="1"/>
<!-- Buttons row -->
<rect x="32" y="27" width="4" height="2" rx="1" fill="#1f6feb"/>
<rect x="37" y="27" width="4" height="2" rx="1" fill="#30363d"/>
<rect x="42" y="27" width="4" height="2" rx="1" fill="#30363d"/>
<rect x="47" y="27" width="5" height="2" rx="1" fill="#238636"/>
<!-- ANT connector -->
<circle cx="52" cy="12" r="2" fill="#30363d" stroke="#8b949e" stroke-width="0.8"/>
</svg>
<div>
<div style="font-size:14px;font-weight:700;color:#58a6ff" id="rs-model">FLEX Radio</div>
<div style="font-size:11px;color:#8b949e" id="rs-ip-sub">Not connected</div>
</div>
</div>
<div class="rs">
<div>
<div class="rr"><span class="rk">IP Address</span><span class="rv" id="rs-ip">--</span></div>
<div class="rr"><span class="rk">Band</span><span class="rv b" id="rs-band">--</span></div>
<div class="rr"><span class="rk">Mode</span><span class="rv" id="rs-mode">--</span></div>
</div>
<div>
<div class="rr"><span class="rk">Frequency</span><span class="rv y" id="rs-freq">--</span></div>
<div class="rr"><span class="rk">TX State</span><span class="rv g" id="rs-tx">RX</span></div>
</div>
</div>
</div>
<!-- Active Antenna -->
<div class="card">
<div class="ct">📡 Active Antenna</div>
<div class="aa">
<div id="aa-ic" style="margin:6px 0 4px"><div id="aa-svg-wrap">
<svg id="aa-svg" width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Yagi antenna: boom + elements + mast -->
<line x1="32" y1="8" x2="32" y2="56" stroke="#58a6ff" stroke-width="2.5" stroke-linecap="round"/>
<line x1="14" y1="14" x2="50" y2="14" stroke="#3fb950" stroke-width="2" stroke-linecap="round"/>
<line x1="16" y1="22" x2="48" y2="22" stroke="#e6edf3" stroke-width="2" stroke-linecap="round"/>
<line x1="18" y1="30" x2="46" y2="30" stroke="#e6edf3" stroke-width="2" stroke-linecap="round"/>
<line x1="20" y1="38" x2="44" y2="38" stroke="#e6edf3" stroke-width="2" stroke-linecap="round"/>
<line x1="22" y1="46" x2="42" y2="46" stroke="#e6edf3" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="32" cy="8" r="3" fill="#58a6ff"/>
<line x1="32" y1="56" x2="26" y2="64" stroke="#8b949e" stroke-width="1.5"/>
<line x1="32" y1="56" x2="38" y2="64" stroke="#8b949e" stroke-width="1.5"/>
</svg></div>
</div>
<div class="aa-name" id="aa-name">--</div>
<div class="aa-relay" id="aa-relay">--</div>
<div style="font-size:11px;color:#8b949e;margin-top:4px" id="aa-band-row">Band: --</div>
</div>
</div>
</div>
<div class="g2 mb14">
<!-- Relay Status -->
<div class="card">
<div class="ct">⚡ Relay Status</div>
<div id="rel-stat"></div>
</div>
<!-- Manual Control -->
<div class="card">
<div class="ct">🕹️ Manual Control</div>
<div class="g4 mb14" id="man-btns"></div>
<button id="amode-btn" class="amode auto" onclick="toggleAuto()">AUTO MODE</button>
<div class="amode-sub" id="amode-sub">Following Band Assignment</div>
</div>
</div>
<!-- Quick Actions row -->
<div class="card">
<div class="ct">⚡ Quick Actions</div>
<div class="qa">
<button class="qb" onclick="refresh()">🔄 Refresh</button>
<button class="qb" onclick="doSave()">💾 Save Config</button>
<button class="qb" onclick="reconnect()">🔌 Reconnect</button>
<button class="qb d" onclick="confirmReboot()">🔁 Reboot</button>
</div>
</div>
</div>
<!-- ══ BAND SETUP ══ -->
<div id="pg-bandsetup" class="page">
<div class="card">
<div class="ct">📻 Band to Antenna Assignment</div>
<p style="font-size:12px;color:#8b949e;margin-bottom:14px">Assign each amateur band to a relay. Only one relay activates at a time.</p>
<table class="bt" style="width:100%">
<thead><tr><th>Band</th><th>Frequency Range</th><th>Relay Assignment</th></tr></thead>
<tbody id="bs-body"></tbody>
</table>
<div style="margin-top:14px;display:flex;gap:10px">
<button class="bp" onclick="saveBandMap()">💾 Save Band Map</button>
<button class="bs" onclick="resetBandMap()">↩️ Reset Defaults</button>
</div>
</div>
</div>
<!-- ══ ANTENNAS ══ -->
<div id="pg-antennas" class="page">
<div class="card">
<div class="ct">📡 Antenna Names</div>
<p style="font-size:12px;color:#8b949e;margin-bottom:16px">Rename each relay output. Names appear throughout the UI and logs.</p>
<div id="ant-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:14px"></div>
<div style="margin-top:18px"><button class="bp" onclick="saveAnts()">💾 Save Names</button></div>
</div>
</div>
<!-- ══ SETTINGS ══ -->
<div id="pg-settings" class="page">
<!-- WiFi Status Card -->
<div class="card mb14">
<div class="ct">🛜 WiFi Status</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:2px 14px">
<div class="sr"><span class="sk">SSID</span><span class="sv" id="wp-ssid">--</span></div>
<div class="sr"><span class="sk">IP Address</span><span class="sv" id="wp-ip">--</span></div>
<div class="sr"><span class="sk">RSSI</span><span class="sv" id="wp-rssi">--</span></div>
<div class="sr"><span class="sk">MAC</span><span class="sv" id="wp-mac">--</span></div>
</div>
<div style="margin-top:12px"><button class="bd" onclick="resetWifi()">🔄 Change WiFi Network</button></div>
</div>
<!-- FLEX Connection Card -->
<div class="card mb14">
<div class="ct">🔌 Flex Connection</div>
<!-- Live status strip -->
<div style="background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:10px 14px;margin-bottom:14px;display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<span id="sx-badge" class="badge br">Disconnected</span>
<span style="font-size:12px;color:#8b949e">IP: <span id="sx-ip" style="color:#e6edf3">--</span></span>
<span style="font-size:12px;color:#8b949e">Band: <span id="sx-band" style="color:#58a6ff">--</span></span>
<span style="font-size:12px;color:#8b949e">TX: <span id="sx-tx" style="color:#3fb950">--</span></span>
<span style="margin-left:auto;font-size:11px;color:#8b949e">Last err: <span id="sx-err" style="color:#f85149">None</span></span>
</div>
<!-- Mode selector -->
<div class="set-row">
<div><div class="set-lbl">Connection Mode</div><div class="set-desc">Auto UDP discovery or fixed IP address</div></div>
<select id="sx-mode" onchange="sxToggle()">
<option value="auto">Auto Discovery</option>
<option value="manual">Manual IP</option>
</select>
</div>
<!-- Manual IP row -->
<div id="sx-iprow" style="display:none;padding:10px 0 2px">
<div style="font-size:12px;color:#8b949e;margin-bottom:6px">FLEX-6300 IP Address</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<input type="text" id="sx-ipin" placeholder="192.168.0.13" style="max-width:180px">
<span style="font-size:11px;color:#8b949e">e.g. 192.168.0.13</span>
</div>
</div>
<!-- Buttons -->
<div style="margin-top:14px;display:flex;gap:8px;flex-wrap:wrap">
<button class="bp" onclick="sxSave()">💾 Save & Connect</button>
<button class="bs" onclick="sxDiscover()">🔍 Discover Now</button>
<button class="bs" onclick="sxReconnect()">🔌 Reconnect</button>
</div>
<div id="sx-st" style="margin-top:8px;font-size:12px;color:#8b949e;min-height:16px"></div>
<!-- Discovery results list -->
<div id="sx-disc-results" style="display:none;margin-top:10px">
<div style="font-size:12px;color:#8b949e;margin-bottom:7px">Found radios – tap to select:</div>
<div id="sx-disc-list"></div>
</div>
</div>
<!-- Controller Settings Card -->
<div class="card">
<div class="ct">⚙️ Controller Settings</div>
<div class="set-row">
<div><div class="set-lbl">TX Safety Lock</div><div class="set-desc">Block relay switching while radio is transmitting</div></div>
<label class="tog"><input type="checkbox" id="s-txlock" onchange="setSetting('txlock',this.checked)"><span class="tsl"></span></label>
</div>
<div class="set-row">
<div><div class="set-lbl">Auto Reconnect to FLEX</div><div class="set-desc">Reconnect automatically if TCP connection drops</div></div>
<label class="tog"><input type="checkbox" id="s-autorecon" onchange="setSetting('autorecon',this.checked)"><span class="tsl"></span></label>
</div>
<div class="set-row">
<div><div class="set-lbl">Default Boot Mode</div><div class="set-desc">Start in AUTO or MANUAL relay mode after power-on</div></div>
<select id="s-bootmode" onchange="setSetting('bootmode',this.value)">
<option value="auto">Automatic</option>
<option value="manual">Manual</option>
</select>
</div>
<div class="set-row">
<div><div class="set-lbl">Band Change Delay (ms)</div><div class="set-desc">Settle time before relay fires after band change</div></div>
<input type="number" id="s-delay" min="0" max="2000" style="width:80px" onchange="setSetting('delay',this.value)">
</div>
</div>
<!-- Config Backup / Restore -->
<div class="card mb14" style="margin-top:14px">
<div class="ct">💾 Config Backup & Restore</div>
<p style="font-size:12px;color:#8b949e;margin-bottom:12px">Download all settings (band map, antenna names, relay delays) as a JSON file, or restore from a previously saved file.</p>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<button class="bp" onclick="downloadConfig()">⬇️ Backup Config</button>
<label class="bp" style="cursor:pointer;display:inline-block">
⬆️ Restore Config
<input type="file" id="cfg-restore-file" accept=".json" style="display:none" onchange="restoreConfig(this)">
</label>
</div>
<div id="cfg-status" style="margin-top:8px;font-size:12px;color:#8b949e;min-height:16px"></div>
</div>
</div>
<!-- ══ LOGS ══ -->
<div id="pg-logs" class="page">
<div class="card">
<div class="ct">📋 Full Event Log</div>
<div style="display:flex;gap:10px;margin-bottom:12px">
<button class="bs" onclick="clearLogs()">🗑️ Clear Logs</button>
<button class="bs" onclick="dlLogs()">⬇️ Download</button>
</div>
<div class="log-wrap" id="log-full" style="max-height:500px"></div>
</div>
</div>
<!-- ══ DEBUG ══ -->
<div id="pg-debug" class="page">
<div class="g2 mb14">
<div class="card">
<div class="ct">🔌 Connection State</div>
<div class="sr"><span class="sk">State</span><span id="dbg-state" class="badge br">DISCONNECTED</span></div>
<div class="sr"><span class="sk">FLEX IP</span><span class="sv" id="dbg-ip">--</span></div>
<div class="sr"><span class="sk">Reconnects</span><span class="sv" id="dbg-recon">0</span></div>
<div class="sr"><span class="sk">Last Error</span><span class="sv" id="dbg-err" style="color:#f85149">None</span></div>
<div class="sr"><span class="sk">Last RX</span><span class="sv" id="dbg-lastrx" style="font-family:monospace;font-size:11px">--</span></div>
<div style="margin-top:12px;display:flex;gap:8px">
<button class="bs" onclick="discover()">🔍 Rediscover</button>
<button class="bs" onclick="reconnect()">🔌 Reconnect</button>
</div>
</div>
<div class="card">
<div class="ct">📊 Statistics</div>
<div class="sr"><span class="sk">Free Heap</span><span class="sv" id="dbg-heap">--</span></div>
<div class="sr"><span class="sk">CPU Temp</span><span class="sv" id="dbg-temp">--</span></div>
<div class="sr"><span class="sk">Uptime</span><span class="sv" id="dbg-uptime">--</span></div>
<div class="sr"><span class="sk">WiFi RSSI</span><span class="sv" id="dbg-rssi">--</span></div>
<div class="sr"><span class="sk">Relay Bits</span><span class="sv" id="dbg-rel" style="font-family:monospace;letter-spacing:4px">0000</span></div>
</div>
</div>
<div class="card">
<div class="ct">📟 Raw SmartSDR Stream (last 20 lines)</div>
<div class="dbg-str" id="dbg-stream"></div>
</div>
</div>
<!-- ══ SYSTEM ══ -->
<div id="pg-system" class="page">
<div class="card">
<div class="ct">💻 System Information</div>
<div class="sr"><span class="sk">Firmware</span><span class="sv">v1.0.0</span></div>
<div class="sr"><span class="sk">Chip</span><span class="sv">ESP32</span></div>
<div class="sr"><span class="sk">CPU Frequency</span><span class="sv">240 MHz</span></div>
<div class="sr"><span class="sk">Free Heap</span><span class="sv" id="sys-heap">--</span></div>
<div class="sr"><span class="sk">CPU Temperature</span><span class="sv" id="sys-temp">--</span></div>
<div class="sr"><span class="sk">Uptime</span><span class="sv" id="sys-up">--</span></div>
<div class="sr"><span class="sk">WiFi SSID</span><span class="sv" id="sys-ssid">--</span></div>
<div class="sr"><span class="sk">IP Address</span><span class="sv" id="sys-ip">--</span></div>
<div class="sr"><span class="sk">mDNS</span><span class="sv">rem-relay.local</span></div>
<div style="margin-top:18px;display:flex;gap:10px;flex-wrap:wrap">
<button class="bd" onclick="confirmReboot()">🔁 Reboot</button>
<button class="bs" onclick="resetWifi()">🗑️ Reset WiFi</button>
<button class="bs" onclick="factoryReset()">⚠️ Factory Reset</button>
</div>
</div>
</div>
<!-- ══ ABOUT ══ -->
<div id="pg-about" class="page">
<!-- Header card -->
<div class="card mb14" style="text-align:center;padding:22px">
<div style="font-size:48px;margin-bottom:8px">📡</div>
<h2 style="color:#58a6ff;font-size:18px;margin-bottom:4px">REM-Relay — Remote Relay Controller</h2>
<p style="color:#8b949e;font-size:12px;margin-bottom:2px">4 Relay · 1 Coax · Fully Automatic</p>
<p style="color:#8b949e;font-size:12px">Firmware <span style="color:#3fb950;font-weight:700">v1.0.0</span> · ESP32 240 MHz · <span style="color:#58a6ff">rem-relay.local</span></p>
</div>
<!-- Features -->
<div class="card mb14">
<div class="ct">📋 What's Included</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px 16px;font-size:12px;line-height:1.9">
<div style="color:#3fb950">✅ UDP auto-discovery of Flex radios</div>
<div style="color:#3fb950">✅ FLEX-6300 / 6400 / 6600 / 6700 / 8600 support</div>
<div style="color:#3fb950">✅ Real-time frequency in Hz (14.250.555 format)</div>
<div style="color:#3fb950">✅ Auto relay switching by band (160m – 6m)</div>
<div style="color:#3fb950">✅ Band derived from frequency as fallback</div>
<div style="color:#3fb950">✅ Per-band relay assignment saved to NVS</div>
<div style="color:#3fb950">✅ TX Safety Lock – no switch while transmitting</div>
<div style="color:#3fb950">✅ ATU awareness – relay locked during ATU tune</div>
<div style="color:#3fb950">✅ Auto & Manual relay control modes</div>
<div style="color:#3fb950">✅ Instant relay response (no browser lag)</div>
<div style="color:#3fb950">✅ Rename antenna labels (4 relays)</div>
<div style="color:#3fb950">✅ Upload custom antenna image per relay</div>
<div style="color:#3fb950">✅ WiFi signal bar in UI topbar</div>
<div style="color:#3fb950">✅ 0.96" OLED: IP / Band / Antenna / TX state</div>
<div style="color:#3fb950">✅ Config backup & restore (JSON download)</div>
<div style="color:#3fb950">✅ OTA firmware update via web browser</div>
<div style="color:#3fb950">✅ First-boot WiFi scan & captive portal</div>
<div style="color:#3fb950">✅ mDNS – rem-relay.local</div>
<div style="color:#3fb950">✅ SmartSDR API v1.x / v2.x / v3.x</div>
<div style="color:#3fb950">✅ WiFi reset via BOOT button hold (5s)</div>
</div>
</div>
<!-- OTA Firmware Update -->
<div class="card">
<div class="ct">🔄 Firmware Update (OTA)</div>
<p style="font-size:12px;color:#8b949e;margin-bottom:14px">
Upload a new <code style="background:#21262d;padding:1px 5px;border-radius:3px;color:#e3b341">.bin</code> firmware file compiled from Arduino IDE.
The ESP32 will flash and restart automatically.
</p>
<div style="background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:12px;margin-bottom:14px">
<div style="font-size:11px;color:#8b949e;margin-bottom:8px">How to get the .bin file from Arduino IDE:</div>
<div style="font-size:11px;color:#e6edf3;line-height:1.8">
1. Open your sketch in Arduino IDE<br>
2. Go to <strong>Sketch → Export Compiled Binary</strong><br>
3. Find the <code style="background:#21262d;padding:1px 4px;border-radius:2px;color:#e3b341">.bin</code> file in your sketch folder<br>
4. Upload it below ↓
</div>
</div>
<div id="ota-area" style="border:2px dashed #30363d;border-radius:8px;padding:20px;text-align:center">
<div style="font-size:28px;margin-bottom:8px">📦</div>
<div style="font-size:13px;color:#8b949e;margin-bottom:12px">Select firmware .bin file to upload</div>
<label style="background:#238636;border:none;border-radius:6px;color:#fff;padding:9px 18px;font-size:13px;font-weight:600;cursor:pointer;display:inline-block">
📁 Choose .bin File
<input type="file" id="ota-file" accept=".bin" style="display:none" onchange="otaFileChosen(this)">
</label>
<div id="ota-filename" style="margin-top:8px;font-size:12px;color:#8b949e"></div>
</div>
<div id="ota-progress-wrap" style="display:none;margin-top:14px">
<div style="display:flex;justify-content:space-between;font-size:12px;color:#8b949e;margin-bottom:5px">
<span>Uploading firmware…</span><span id="ota-pct">0%</span>
</div>
<div style="background:#21262d;border-radius:4px;height:8px;overflow:hidden">
<div id="ota-bar" style="height:100%;width:0%;background:#238636;transition:width .3s;border-radius:4px"></div>
</div>
</div>
<div id="ota-status" style="margin-top:10px;font-size:13px;min-height:18px"></div>
<div id="ota-btn-wrap" style="display:none;margin-top:12px">
<button class="bp" onclick="otaUpload()">⚡ Flash Firmware Now</button>
</div>
</div>
</div>
</div><!-- /content -->
</div><!-- /main -->
<script>
// ── State ──────────────────────────────────────────────
var S={};
var RC=['#1f6feb','#238636','#9e6a03','#6e40c9'];
var RCB=['#1f3a5f','#1a3a2a','#3a2a1a','#2a1a4a'];
// ── Navigation ─────────────────────────────────────────
function pg(id,el){
if(id==='bandsetup'){bsBuilt=false;bsData=null;bsEdits={};}
document.querySelectorAll('.page').forEach(function(p){p.classList.remove('on')});
document.querySelectorAll('.nav').forEach(function(n){n.classList.remove('on')});
document.getElementById('pg-'+id).classList.add('on');
if(el) el.classList.add('on');
currentPg=id;
refresh();
}
var currentPg='dashboard';
// ── Helpers ─────────────────────────────────────────────
function txt(id,v){var e=document.getElementById(id);if(e)e.textContent=v}
function fmtUp(s){var h=Math.floor(s/3600),m=Math.floor((s%3600)/60),ss=s%60;
return h+'h '+m+'m '+ss+'s'}
function fmtUpShort(s){var h=Math.floor(s/3600),m=Math.floor((s%3600)/60),ss=s%60;
return h+':'+String(m).padStart(2,'0')+':'+String(ss).padStart(2,'0')}
// ── Refresh ─────────────────────────────────────────────
function refresh(){
fetch('/api/state').then(function(r){return r.json()}).then(function(d){S=d;draw(d)}).catch(function(){});
}
function draw(d){
// Topbar
txt('tb-up', fmtUpShort(d.uptime||0));
var fb=document.getElementById('tb-flex');
if(d.flexConnected){fb.textContent='FLEX Connected';fb.className='badge bg';}
else{fb.textContent='FLEX Disconnected';fb.className='badge br';}
// Radio Status
txt('rs-ip', d.flexIP||'--');
txt('rs-model', d.radioModel||'FLEX Radio');
txt('rs-ip-sub', d.flexIP||(d.flexConnected?'Connected':'Not connected'));
txt('sb-model', (d.radioModel||'FLEX Radio')+' Controller');
txt('rs-band', d.band||'--');
txt('rs-freq', d.frequency||'--');
txt('rs-mode', d.mode||'--');
var txe=document.getElementById('rs-tx');
if(txe){
var txLabel=d.txState==='TRANSMITTING'?'TX':'RX';
txe.textContent=txLabel;
txe.className='rv '+(d.txState==='TRANSMITTING'?'r':'g');
}
txt('rs-pwr', (d.rfPower||0)+' W');
txt('rs-swr', parseFloat(d.swr||1).toFixed(1)+' : 1');
// Active Antenna – derive from band map in auto mode, relay state in manual
var aaRelay=-1;
if(d.autoMode&&d.bandMap&&d.band&&d.band!='--'){
for(var bi=0;bi<d.bandMap.length;bi++){
if(d.bandMap[bi].band===d.band){aaRelay=d.bandMap[bi].relay-1;break;}
}
}
if(aaRelay<0&&d.relays) aaRelay=d.relays.indexOf(true);
// antenna image handled by updateActiveAntImg()
txt('aa-name', aaRelay>=0&&d.antNames?d.antNames[aaRelay]:'--');
txt('aa-relay', aaRelay>=0?'RELAY '+(aaRelay+1):'--');
txt('aa-band-row','Band: '+(d.band||'--')+' / '+(d.frequency||'--'));
updateActiveAntImg();
// Relay status list
if(d.relays&&d.antNames) buildRelStat(d.relays,d.antNames);
// Manual buttons
if(d.relays&&d.antNames) buildManBtns(d.relays,d.antNames);
// Auto mode btn
var ab=document.getElementById('amode-btn');
if(ab){ab.textContent=d.autoMode?'AUTO MODE':'MANUAL MODE';ab.className='amode '+(d.autoMode?'auto':'man');}
txt('amode-sub', d.autoMode?'Following Band Assignment':'Manual Control Active');
// Logs – only full page
if(currentPg==='logs'&&d.logs) buildLog('log-full',d.logs,50);
// Debug
if(currentPg==='debug'){
var de=document.getElementById('dbg-state');
if(de){de.textContent=d.flexConnected?'CONNECTED':'DISCONNECTED';de.className='badge '+(d.flexConnected?'bg':'br');}
txt('dbg-ip', d.flexIP||'--');
txt('dbg-recon', d.reconnectCount||0);
txt('dbg-err', d.lastError||'None');
txt('dbg-heap', Math.round((d.freeHeap||0)/1024)+' KB');
txt('dbg-temp', (d.cpuTemp||'--')+' °C');
txt('dbg-uptime',fmtUp(d.uptime||0));
txt('dbg-rssi', (d.rssi||'--')+' dBm');
txt('dbg-rel', d.relays?d.relays.map(function(r){return r?1:0}).join(''):'0000');
txt('dbg-lastrx',d.lastRX||'--');
if(d.debugLogs) buildDbgStream(d.debugLogs);
}
// WiFi status (always update – now lives in Settings)
txt('wp-ssid', d.wifiSSID||'--');
txt('wp-ip', d.wifiIP||'--');
txt('wp-rssi', (d.rssi||'--')+' dBm');
txt('wp-mac', d.mac||'--');
// RSSI signal bar in topbar
updateRSSIBar(d.rssi||(-100));
// ATU tuning badge
var atuBadge=document.getElementById('tb-atu');
if(atuBadge){atuBadge.style.display=d.atuTuning?'inline':'none';}
// ATU lock in manual buttons (dim them while tuning)
var manBtns=document.querySelectorAll('.mb');
manBtns.forEach(function(b){b.style.opacity=d.atuTuning?'0.3':'1';b.title=d.atuTuning?'ATU tuning – wait…':''});
// System page
if(currentPg==='system'){
txt('sys-heap', Math.round((d.freeHeap||0)/1024)+' KB');
txt('sys-temp', (d.cpuTemp||'--')+' °C');
txt('sys-up', fmtUp(d.uptime||0));
txt('sys-ssid', d.wifiSSID||'--');
txt('sys-ip', d.wifiIP||'--');
}
// Band setup
if(currentPg==='bandsetup'&&d.bandMap&&d.antNames) buildBsTable(d.bandMap,d.antNames);
// Antennas
if(currentPg==='antennas'&&d.antNames) buildAntGrid(d.antNames);
// Settings
if(currentPg==='settings'){
var tl=document.getElementById('s-txlock');if(tl&&d.txLock!==undefined)tl.checked=d.txLock;
var ar2=document.getElementById('s-autorecon');if(ar2&&d.autoReconnect!==undefined)ar2.checked=d.autoReconnect;
var bm=document.getElementById('s-bootmode');if(bm)bm.value=d.autoMode?'auto':'manual';
var dl=document.getElementById('s-delay');if(dl&&d.swDelay!==undefined)dl.value=d.swDelay;
// FLEX status strip
var sxb=document.getElementById('sx-badge');
if(sxb){sxb.textContent=d.flexConnected?'Connected':'Disconnected';sxb.className='badge '+(d.flexConnected?'bg':'br');}
txt('sx-ip', d.flexIP||'--');
txt('sx-band', d.band||'--');
var sxtx=document.getElementById('sx-tx');
if(sxtx){
var sxTxLabel=d.txState==='TRANSMITTING'?'TX':'RX';
sxtx.textContent=sxTxLabel;
sxtx.style.color=d.txState==='TRANSMITTING'?'#f85149':'#3fb950';
}
txt('sx-err', d.lastError||'None');
// Populate IP field & mode dropdown (only if not being edited)
var msel=document.getElementById('sx-mode');
if(msel&&document.activeElement!==msel){
msel.value=d.useManualIP?'manual':'auto';
sxToggle();
}
var ipin=document.getElementById('sx-ipin');
if(ipin&&document.activeElement!==ipin&&d.flexManualIP){ipin.value=d.flexManualIP;}
else if(ipin&&document.activeElement!==ipin&&d.flexIP){ipin.value=d.flexIP;}
}
}
// ── UI builders ─────────────────────────────────────────
function buildRelStat(relays,names){
var el=document.getElementById('rel-stat');if(!el)return;
var h='';
relays.forEach(function(on,i){
h+='<div class="ri"><div class="rnum">'+(i+1)+'</div>'
+'<div class="rname">'+names[i]+'</div>'
+'<label class="tog"><input type="checkbox"'+(on?' checked':'')
+' onchange="togRelay('+(i+1)+',this.checked)"><span class="tsl"></span></label>'
+'<span class="badge '+(on?'bg':'br')+'" style="margin-left:8px;min-width:34px;text-align:center">'
+(on?'ON':'OFF')+'</span></div>';
});
el.innerHTML=h;
}
function buildManBtns(relays,names){
var el=document.getElementById('man-btns');if(!el)return;
var h='';
relays.forEach(function(on,i){
var style=on?'border-color:'+RC[i]+';background:'+RCB[i]:'';
h+='<div class="mb'+(on?' on':'')+'" style="'+style+'" onclick="actRelay('+(i+1)+')">'
+'<div class="mn">RELAY '+(i+1)+'</div>'
+'<div class="mna">'+names[i]+'</div></div>';
});
el.innerHTML=h;
}
function buildBmDash(bm,names,activeBand){
var el=document.getElementById('bm-dash');if(!el)return;
var h='';
bm.forEach(function(b){
var ac=b.band===activeBand;
var ri=b.relay-1;
var col=ri>=0?RC[ri]:'#8b949e',bg=ri>=0?RCB[ri]:'#21262d';
var an=b.relay>0&&names?names[b.relay-1]:'--';
h+='<tr'+(ac?' class="ar"':'')+'>'
+'<td><strong style="color:'+(ac?'#e3b341':'#e6edf3')+'">'+b.band+'</strong></td>'
+'<td style="color:#8b949e;font-size:11px">'+b.fStart+'–'+b.fEnd+'</td>'
+'<td><span class="rbadge" style="background:'+bg+';color:'+col+';border:1px solid '+col+'">R'+b.relay+'</span></td>'
+'<td style="font-size:11px">'+an+'</td></tr>';
});
el.innerHTML=h;
}
var bsBuilt=false;
var bsData=null;
function buildBsTable(bm,names){
// Only (re)build if data actually changed or first load
var key=JSON.stringify(bm)+JSON.stringify(names);
if(bsBuilt&&bsData===key)return;
bsBuilt=true;bsData=key;
var el=document.getElementById('bs-body');if(!el)return;
var h='';
bm.forEach(function(b,i){
var opts='';
for(var r=1;r<=4;r++){opts+='<option value="'+r+'"'+(b.relay===r?' selected':'')+'>'
+r+' – '+(names?names[r-1]:'Ant '+r)+'</option>';}
h+='<tr><td><strong>'+b.band+'</strong></td>'
+'<td style="color:#8b949e;font-size:12px">'+b.fStart+' – '+b.fEnd+' MHz</td>'
+'<td><select class="rsel" id="bsr-'+i+'" onchange="bsChanged('+i+',this.value)">'+opts+'</select></td></tr>';
});
el.innerHTML=h;
applyBsEdits();
}
var antBuilt=false;
// Antenna images stored in localStorage as base64
function getAntImg(i){try{return localStorage.getItem('ant_img_'+i)||'';}catch(e){return '';}}
function setAntImg(i,data){try{localStorage.setItem('ant_img_'+i,data);}catch(e){}}
function clearAntImg(i){try{localStorage.removeItem('ant_img_'+i);}catch(e){}}
function buildAntGrid(names){
var el=document.getElementById('ant-grid');if(!el)return;
var h='';
names.forEach(function(n,i){
var img=getAntImg(i);
h+='<div style="background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:14px">'
+'<div style="font-size:11px;color:#8b949e;margin-bottom:8px;text-transform:uppercase;letter-spacing:.6px">RELAY '+(i+1)+'</div>'
+'<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">'
+'<div id="ant-img-prev-'+i+'" style="width:64px;height:64px;background:#161b22;border:1px solid #30363d;border-radius:6px;display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0">'
+(img?'<img src="'+img+'" style="max-width:62px;max-height:62px;object-fit:contain">':'<svg width="32" height="32" viewBox="0 0 64 64" fill="none"><line x1="32" y1="8" x2="32" y2="56" stroke="#30363d" stroke-width="2.5"/><line x1="14" y1="14" x2="50" y2="14" stroke="#30363d" stroke-width="2"/><line x1="16" y1="22" x2="48" y2="22" stroke="#21262d" stroke-width="2"/><line x1="18" y1="30" x2="46" y2="30" stroke="#21262d" stroke-width="2"/></svg>')
+'</div>'
+'<div style="flex:1">'
+'<input type="text" id="an-'+i+'" value="'+n+'" placeholder="Antenna name…" style="width:100%;margin-bottom:8px">'
+'<div style="display:flex;gap:6px;flex-wrap:wrap">'
+'<label style="background:#21262d;border:1px solid #30363d;border-radius:5px;padding:5px 10px;font-size:12px;cursor:pointer;color:#e6edf3">📁 Upload Image<input type="file" accept="image/*" style="display:none" onchange="antImgUpload('+i+',this)"></label>'
+(img?'<button class="bs" style="font-size:12px;padding:5px 10px" onclick="antImgClear('+i+')">✕ Clear</button>':'')
+'</div></div></div></div>';
});
el.innerHTML=h;
antBuilt=true;
}
function antImgUpload(i,inp){
var file=inp.files[0];if(!file)return;
var r=new FileReader();
r.onload=function(e){
setAntImg(i,e.target.result);
antBuilt=false;
buildAntGrid(S.antNames||['Hexbeam','Vertical','Loop','6m Beam']);
updateActiveAntImg();
};
r.readAsDataURL(file);
}
function antImgClear(i){
clearAntImg(i);
antBuilt=false;
buildAntGrid(S.antNames||['Hexbeam','Vertical','Loop','6m Beam']);
updateActiveAntImg();
}
function updateActiveAntImg(){
// Update active antenna display on dashboard
var aaRelay=-1;
if(S.autoMode&&S.bandMap&&S.band&&S.band!='--'){
for(var bi=0;bi<S.bandMap.length;bi++){
if(S.bandMap[bi].band===S.band){aaRelay=S.bandMap[bi].relay-1;break;}
}
}
if(aaRelay<0&&S.relays) aaRelay=S.relays.indexOf(true);
var ic=document.getElementById('aa-svg-wrap');
if(!ic) return;
var img=aaRelay>=0?getAntImg(aaRelay):'';
if(img){
ic.innerHTML='<img src="'+img+'" style="max-width:64px;max-height:64px;object-fit:contain">';
} else {
var cols=['#3fb950','#58a6ff','#e3b341','#f85149'];
var c=aaRelay>=0?cols[aaRelay]:'#58a6ff';
ic.innerHTML='<svg width="64" height="64" viewBox="0 0 64 64" fill="none"><line x1="32" y1="8" x2="32" y2="56" stroke="'+c+'" stroke-width="2.5" stroke-linecap="round"/><line x1="14" y1="14" x2="50" y2="14" stroke="'+c+'" stroke-width="2" stroke-linecap="round"/><line x1="16" y1="22" x2="48" y2="22" stroke="#e6edf3" stroke-width="2" stroke-linecap="round"/><line x1="18" y1="30" x2="46" y2="30" stroke="#e6edf3" stroke-width="2" stroke-linecap="round"/><line x1="20" y1="38" x2="44" y2="38" stroke="#e6edf3" stroke-width="2" stroke-linecap="round"/><line x1="22" y1="46" x2="42" y2="46" stroke="#e6edf3" stroke-width="1.5" stroke-linecap="round"/><circle cx="32" cy="8" r="3" fill="'+c+'"/></svg>';
}
}
function buildRelPage(relays,names){
var el=document.getElementById('rel-page');if(!el)return;
var h='';var pins=[26,27,25,33];
relays.forEach(function(on,i){
h+='<div class="ri" style="padding:14px 0">'
+'<div class="rnum" style="width:30px;height:30px;font-size:13px">'+(i+1)+'</div>'
+'<div style="flex:1"><div style="font-size:14px;font-weight:600">'+names[i]+'</div>'
+'<div style="font-size:11px;color:#8b949e">GPIO '+pins[i]+'</div></div>'
+'<label class="tog"><input type="checkbox"'+(on?' checked':'')
+' onchange="togRelay('+(i+1)+',this.checked)"><span class="tsl"></span></label>'
+'<span class="badge '+(on?'bg':'br')+'" style="margin-left:12px;min-width:38px;text-align:center">'
+(on?'ON':'OFF')+'</span></div>';
});
el.innerHTML=h;
}
function buildLog(id,logs,limit){
var el=document.getElementById(id);if(!el)return;
var items=logs.slice().reverse().slice(0,limit);
var h='';
items.forEach(function(e){
var sp=e.indexOf(' ');
var ts=e.substring(0,sp),msg=e.substring(sp+1);
var cls='';
if(msg.indexOf('FAIL')>=0||msg.indexOf('ERROR')>=0||msg.indexOf('BLOCK')>=0)cls='e';
else if(msg.indexOf('LOCK')>=0||msg.indexOf('WARN')>=0)cls='w';
else if(msg.indexOf('Connect')>=0||msg.indexOf('discover')>=0||msg.indexOf('discov')>=0)cls='i';
h+='<div class="le"><span class="lt">'+ts+'</span><span class="lm '+cls+'">'+msg+'</span></div>';
});
el.innerHTML=h;
}
function buildDbgStream(logs){
var el=document.getElementById('dbg-stream');if(!el)return;
var items=logs.slice().reverse().slice(0,20);
var h='';
items.forEach(function(e){
var sp=e.indexOf(' ');
var ts=e.substring(0,sp),msg=e.substring(sp+1);
h+='<div><span class="dts">'+ts+'</span>'+msg+'</div>';
});
el.innerHTML=h;
}
// ── API calls ────────────────────────────────────────────
function post(url,body){return fetch(url,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body})}
function postJ(url,obj){return fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(obj)})}
function togRelay(n,s){post('/api/relay','relay='+n+'&state='+(s?1:0)).then(refresh)}
function actRelay(n){post('/api/activate','relay='+n).then(refresh)}
function toggleAuto(){post('/api/automode','').then(refresh)}
function doSave(){fetch('/api/saveconfig',{method:'POST'}).then(function(r){return r.json()}).then(function(d){alert(d.ok?'✅ Config saved!':'❌ Save failed')})}
function discover(){post('/api/discover','').then(function(){refresh()})}
function reconnect(){post('/api/reconnect','').then(refresh)}
function clearLogs(){post('/api/clearlogs','').then(refresh)}
function dlLogs(){var b=new Blob([(S.logs||[]).join('\n')],{type:'text/plain'});var a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='antctrl_log.txt';a.click()}
function resetWifi(){if(confirm('Clear WiFi credentials and restart in AP mode?')){post('/api/resetwifi','');setTimeout(function(){window.location.href='http://192.168.4.1'},3000)}}
function factoryReset(){if(confirm('⚠️ Erase ALL settings? This cannot be undone.')){post('/api/factoryreset','');setTimeout(function(){window.location.href='http://192.168.4.1'},3500)}}
function confirmReboot(){if(confirm('Reboot ESP32?')){post('/api/reboot','');setTimeout(function(){location.reload()},6000)}}
// Track in-memory edits so polling doesn't overwrite user selections
var bsEdits={};
function bsChanged(i,v){
bsEdits[i]=parseInt(v);
// update antenna name preview cell
}
// Override buildBsTable to respect in-memory edits
function applyBsEdits(){
for(var k in bsEdits){
var s=document.getElementById('bsr-'+k);
if(s)s.value=bsEdits[k];
}
}
function saveBandMap(){
var data={};
for(var i=0;i<11;i++){var s=document.getElementById('bsr-'+i);if(s)data['r'+i]=parseInt(s.value)}
postJ('/api/bandmap',data).then(function(){bsEdits={};bsBuilt=false;bsData=null;alert('✅ Band map saved!');refresh()});
}
function saveAnts(){
var names=[];
for(var i=0;i<4;i++){var inp=document.getElementById('an-'+i);names.push(inp?inp.value:'Antenna '+(i+1))}
postJ('/api/antnames',{names:names}).then(function(){antBuilt=false;alert('✅ Names saved!');refresh()});
}
function setSetting(k,v){post('/api/setting','key='+k+'&val='+encodeURIComponent(v))}
function toggleManIP(){
var m=document.getElementById('conn-mode').value;
document.getElementById('man-ip-row').style.display=m==='manual'?'block':'none';
}
function saveFlexConn(){
var m=document.getElementById('conn-mode').value;
var ip=document.getElementById('flex-ip-in').value;
post('/api/flexconn','mode='+m+'&ip='+encodeURIComponent(ip))
.then(function(){document.getElementById('flex-conn-st').textContent='Saved – reconnecting…';refresh()});
}
function resetBandMap(){if(!confirm('Reset to defaults?'))return;post('/api/resetbandmap','').then(function(){bsBuilt=false;refresh()})}
// ── Settings page FLEX connection helpers ───────────────
function sxToggle(){
var m=document.getElementById('sx-mode').value;
document.getElementById('sx-iprow').style.display=m==='manual'?'block':'none';
}
function sxSave(){
var m=document.getElementById('sx-mode').value;
var ip=document.getElementById('sx-ipin').value.trim();
if(m==='manual'&&ip.length<7){document.getElementById('sx-st').textContent='⚠️ Enter a valid IP address first';return;}
document.getElementById('sx-st').textContent='Saving…';
post('/api/flexconn','mode='+m+'&ip='+encodeURIComponent(ip))
.then(function(){document.getElementById('sx-st').textContent='✅ Saved – connecting…';setTimeout(refresh,1500)});
}
function sxDiscover(){
document.getElementById('sx-st').textContent='🔍 Scanning network for FLEX radios…';
document.getElementById('sx-disc-results').style.display='none';
post('/api/discover','').then(function(){
// Poll for up to 15s waiting for discovery result
var attempts=0;
var poll=setInterval(function(){
attempts++;
fetch('/api/state').then(function(r){return r.json()}).then(function(d){
if(d.flexConnected){
clearInterval(poll);
document.getElementById('sx-st').textContent='✅ Found & connected: '+d.radioModel+' @ '+d.flexIP;
document.getElementById('sx-disc-results').style.display='none';
refresh();
} else if(d.lastError&&d.lastError.indexOf('timeout')>=0){
clearInterval(poll);
document.getElementById('sx-st').textContent='⚠️ No radio found on network. Try manual IP.';
} else if(attempts>=10){
clearInterval(poll);
document.getElementById('sx-st').textContent='⏱ Still searching… check radio is on and on same network.';
}
});
},1500);
});
}
function sxPickRadio(ip,model){
document.getElementById('sx-mode').value='manual';
document.getElementById('sx-ipin').value=ip;
sxToggle();
document.getElementById('sx-st').textContent='Selected '+model+' @ '+ip+' – click Save & Connect';
document.getElementById('sx-disc-results').style.display='none';
}
function sxReconnect(){
document.getElementById('sx-st').textContent='🔌 Reconnecting…';
post('/api/reconnect','').then(function(){setTimeout(function(){refresh();document.getElementById('sx-st').textContent='';},2000)});
}
function otaFileChosen(inp){
var f=inp.files[0];if(!f)return;
document.getElementById('ota-filename').textContent='Selected: '+f.name+' ('+Math.round(f.size/1024)+' KB)';
document.getElementById('ota-btn-wrap').style.display='block';
document.getElementById('ota-area').style.borderColor='#238636';
}
function otaUpload(){
var inp=document.getElementById('ota-file');
var f=inp.files[0];
if(!f){document.getElementById('ota-status').textContent='⚠️ No file selected';return;}
if(!f.name.endsWith('.bin')){document.getElementById('ota-status').textContent='⚠️ Must be a .bin file';return;}
if(!confirm('Flash firmware: '+f.name+'?\nESP32 will restart after flashing.')){return;}
var xhr=new XMLHttpRequest();
var fd=new FormData();
fd.append('firmware',f,f.name);
document.getElementById('ota-progress-wrap').style.display='block';
document.getElementById('ota-btn-wrap').style.display='none';
document.getElementById('ota-status').textContent='';
xhr.upload.onprogress=function(e){
if(e.lengthComputable){
var pct=Math.round(e.loaded/e.total*100);
document.getElementById('ota-bar').style.width=pct+'%';
document.getElementById('ota-pct').textContent=pct+'%';
}
};
xhr.onload=function(){
if(xhr.status===200){
document.getElementById('ota-status').innerHTML='✅ <strong>Flash successful!</strong> ESP32 is restarting…';
document.getElementById('ota-bar').style.background='#238636';
setTimeout(function(){
document.getElementById('ota-status').innerHTML+='<br>Reconnecting in 8s…';
setTimeout(function(){location.reload();},8000);
},1000);
} else {
document.getElementById('ota-status').textContent='❌ Upload failed ('+xhr.status+') – try again';
document.getElementById('ota-progress-wrap').style.display='none';
document.getElementById('ota-btn-wrap').style.display='block';
}
};
xhr.onerror=function(){
document.getElementById('ota-status').textContent='❌ Connection error – ESP32 may have restarted already';
setTimeout(function(){location.reload();},5000);
};
xhr.open('POST','/api/ota');
xhr.send(fd);
}
// ── Boot ─────────────────────────────────────────────────
// ── WiFi RSSI signal bar ─────────────────────────────────
function updateRSSIBar(rssi){
var bars=[document.getElementById('rssi-b1'),document.getElementById('rssi-b2'),
document.getElementById('rssi-b3'),document.getElementById('rssi-b4')];
if(!bars[0])return;
var strength=rssi>=-50?4:rssi>=-65?3:rssi>=-75?2:rssi>=-85?1:0;
var cols=['#3fb950','#3fb950','#e3b341','#f85149'];
bars.forEach(function(b,i){
if(!b)return;
b.style.background=i<strength?(strength>=3?'#3fb950':strength>=2?'#e3b341':'#f85149'):'#30363d';
});
var tb=document.getElementById('tb-rssi-bar');
if(tb)tb.title='WiFi: '+rssi+' dBm';
}
// ── Config backup / restore ──────────────────────────────
function downloadConfig(){
var cfg={
bandMap:S.bandMap?S.bandMap.map(function(b,i){return{band:b.band,relay:b.relay}}):[],
antNames:S.antNames||[],
autoMode:S.autoMode,
txLock:S.txLock,
swDelay:S.swDelay,
useManualIP:S.useManualIP,
flexManualIP:S.flexManualIP||''
};
var blob=new Blob([JSON.stringify(cfg,null,2)],{type:'application/json'});
var a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download='antctrl_config.json';
a.click();
var st=document.getElementById('cfg-status');
if(st)st.textContent='✅ Config downloaded as antctrl_config.json';
}
function restoreConfig(inp){
var file=inp.files[0];if(!file)return;
var st=document.getElementById('cfg-status');
var reader=new FileReader();
reader.onload=function(e){
try{
var cfg=JSON.parse(e.target.result);
// Apply band map
if(cfg.bandMap){
var bm={};
cfg.bandMap.forEach(function(b,i){bm[i]=b.relay;});
postJ('/api/bandmap',bm).then(function(){
// Apply ant names
if(cfg.antNames) postJ('/api/antnames',{names:cfg.antNames});
// Apply settings
if(cfg.txLock!==undefined) post('/api/setting','key=txlock&val='+(cfg.txLock?'true':'false'));
if(cfg.swDelay!==undefined) post('/api/setting','key=delay&val='+cfg.swDelay);
if(cfg.useManualIP!==undefined&&cfg.flexManualIP){
post('/api/flexconn','mode='+(cfg.useManualIP?'manual':'auto')+'&ip='+encodeURIComponent(cfg.flexManualIP));
}
if(st)st.textContent='✅ Config restored! Saving to ESP32…';
setTimeout(function(){fetch('/api/saveconfig',{method:'POST'});refresh();},600);
});
}
}catch(ex){if(st)st.textContent='❌ Invalid config file: '+ex.message;}
};
reader.readAsText(file);
}
setInterval(refresh,1500);
window.onload=function(){
refresh();
};
</script>
</body>
</html>
)====";
// ─────────────────────────────────────────────────────────
// WEB SERVER – AP PORTAL ROUTES
// ─────────────────────────────────────────────────────────
void handleAPRoot() {
server.send_P(200, "text/html", AP_PAGE);
}
void handleScan() {
int n = WiFi.scanNetworks(false, true);
String json = "[";
// De-duplicate by SSID
for (int i = 0; i < n; i++) {
bool dup = false;
for (int j = 0; j < i; j++) {
if (WiFi.SSID(i) == WiFi.SSID(j)) { dup = true; break; }
}
if (dup || WiFi.SSID(i).length() == 0) continue;
if (json.length() > 1) json += ",";
String ssid = WiFi.SSID(i);
ssid.replace("\\", "\\\\"); ssid.replace("\"", "\\\"");
json += "{\"ssid\":\"" + ssid + "\","
+ "\"rssi\":" + WiFi.RSSI(i) + ","
+ "\"enc\":" + (WiFi.encryptionType(i) != WIFI_AUTH_OPEN ? "true" : "false") + "}";
}
json += "]";
server.send(200, "application/json", json);
Serial.println("[SCAN] Found " + String(n) + " networks, sent JSON");
}
void handleConnect() {
if (!server.hasArg("ssid")) { server.send(400, "application/json", "{\"success\":false,\"error\":\"Missing ssid\"}"); return; }
String ssid = server.arg("ssid");
String pass = server.arg("pass");
Serial.println("[WIFI] Attempting: " + ssid);
WiFi.mode(WIFI_STA);
WiFi.begin(ssid.c_str(), pass.c_str());
unsigned long t = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t < 18000) {
delay(300);
Serial.print(".");
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
String ip = WiFi.localIP().toString();
Serial.println("[WIFI] Connected! IP: " + ip);
saveWiFiCreds(ssid, pass);
String resp = "{\"success\":true,\"ip\":\"" + ip + "\"}";
server.send(200, "application/json", resp);
delay(1500);
ESP.restart();
} else {
Serial.println("[WIFI] Failed to connect to: " + ssid);
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, AP_PASS);
server.send(200, "application/json", "{\"success\":false,\"error\":\"Authentication failed or timeout\"}");
}
}
// ─────────────────────────────────────────────────────────
// WEB SERVER – MAIN APP ROUTES
// ─────────────────────────────────────────────────────────
void handleRoot() {
server.send_P(200, "text/html", MAIN_PAGE);
}
// Build full state JSON
String buildStateJSON() {
String j = "{";
// Uptime
j += "\"uptime\":" + String(millis() / 1000) + ",";
// FLEX
j += "\"flexConnected\":" + String(radio.connected ? "true" : "false") + ",";
j += "\"flexIP\":\"" + radio.ip + "\",";
j += "\"radioModel\":\"" + radio.model + "\",";
j += "\"frequency\":\"" + radio.frequency + "\",";
j += "\"band\":\"" + radio.band + "\",";
j += "\"mode\":\"" + radio.mode + "\",";
j += "\"txState\":\"" + radio.txState + "\",";
j += "\"rfPower\":" + String(radio.rfPower, 1) + ",";
j += "\"swr\":" + String(radio.swr, 2) + ",";
// Relays
j += "\"relays\":[";
for (int i = 0; i < 4; i++) { if (i) j += ","; j += relayState[i] ? "true" : "false"; }
j += "],";
// Antenna names
j += "\"antNames\":[";
for (int i = 0; i < 4; i++) {
if (i) j += ",";
String n = antName[i]; n.replace("\"","\\\"");
j += "\"" + n + "\"";
}
j += "],";
// Band map
j += "\"bandMap\":[";
for (int i = 0; i < BAND_COUNT; i++) {
if (i) j += ",";
j += "{\"band\":\"" + String(bandMap[i].band) + "\","
+ "\"fStart\":" + String(bandMap[i].fStart, 3) + ","
+ "\"fEnd\":" + String(bandMap[i].fEnd, 3) + ","
+ "\"relay\":" + String(bandMap[i].relay) + "}";
}
j += "],";
// Mode / settings
j += "\"autoMode\":" + String(autoMode ? "true" : "false") + ",";
j += "\"txLock\":" + String(txLockEnabled ? "true" : "false") + ",";
j += "\"atuTuning\":" + String(atuTuning ? "true" : "false") + ",";
j += "\"autoReconnect\":" + String(autoReconnect ? "true" : "false") + ",";
j += "\"useManualIP\":" + String(useManualIP ? "true" : "false") + ",";
String mi = flexManualIP; mi.replace("\"","\\\"");
j += "\"flexManualIP\":\"" + mi + "\",";
j += "\"swDelay\":" + String(bandSwitchDelay) + ",";
// WiFi
j += "\"wifiSSID\":\"" + WiFi.SSID() + "\",";
j += "\"wifiIP\":\"" + WiFi.localIP().toString() + "\",";
j += "\"rssi\":" + String(WiFi.RSSI()) + ",";
j += "\"mac\":\"" + WiFi.macAddress() + "\",";
// System
j += "\"freeHeap\":" + String(ESP.getFreeHeap()) + ",";
j += "\"cpuTemp\":" + String(42.0f, 1) + ",";
// Debug
j += "\"reconnectCount\":" + String(reconnectCount) + ",";
String le = lastError; le.replace("\"","\\\"");
j += "\"lastError\":\"" + le + "\",";
String lr = lastDebugRX; lr.replace("\"","\\\"");
j += "\"lastRX\":\"" + lr + "\",";
// Event logs
j += "\"logs\":" + logsToJSON(eventLog, logHead, logCount, MAX_LOG) + ",";
// Debug logs
j += "\"debugLogs\":" + logsToJSON(debugLog, dbgHead, dbgCount, MAX_DEBUG);
j += "}";
return j;
}
void handleAPIState() {
String json = buildStateJSON();
server.send(200, "application/json", json);
}
void handleAPIRelay() {
if (!server.hasArg("relay")) { server.send(400); return; }
int r = server.arg("relay").toInt();
int st = server.hasArg("state") ? server.arg("state").toInt() : -1;
if (st == 1) setRelay(r, true);
else if (st == 0) setRelay(r, false);
server.send(200, "application/json", "{\"ok\":true}");
}
void handleAPIActivate() {
if (!server.hasArg("relay")) { server.send(400); return; }
int r = server.arg("relay").toInt();
server.send(200, "application/json", "{\"ok\":true}"); // respond first
activateRelay(r, false); // no blocking delay for manual press
}
void handleAPIAutoMode() {
autoMode = !autoMode;
addLog(autoMode ? "Mode: AUTO" : "Mode: MANUAL");
Serial.println("[MODE] " + String(autoMode ? "AUTO" : "MANUAL"));
server.send(200, "application/json", "{\"ok\":true,\"autoMode\":" + String(autoMode?"true":"false") + "}");
}
void handleAPIDiscover() {
if (!radio.connected) {
startDiscovery();
server.send(200, "application/json", "{\"ok\":true,\"msg\":\"Discovery started\"}");
} else {
server.send(200, "application/json", "{\"ok\":false,\"msg\":\"Already connected\"}");
}
}
void handleAPIReconnect() {
radio.connected = false;
if (flexClient.connected()) flexClient.stop();
if (useManualIP && flexManualIP.length() > 6) {
connectToFlex(flexManualIP);
} else if (radio.ip.length() > 6) {
connectToFlex(radio.ip);
} else {
startDiscovery();
}
server.send(200, "application/json", "{\"ok\":true}");
}
void handleAPISaveConfig() {
saveConfig();
server.send(200, "application/json", "{\"ok\":true}");
}
void handleAPIClearLogs() {
logHead = 0; logCount = 0;
dbgHead = 0; dbgCount = 0;
server.send(200, "application/json", "{\"ok\":true}");
}
void handleAPIReboot() {
server.send(200, "application/json", "{\"ok\":true}");
delay(500);
ESP.restart();
}
void handleAPIResetWifi() {
clearWiFiCreds();
server.send(200, "application/json", "{\"ok\":true}");
delay(500);
ESP.restart();
}
void handleAPIFactoryReset() {
clearWiFiCreds();
prefs.begin("antctrl", false);
prefs.clear();
prefs.end();
server.send(200, "application/json", "{\"ok\":true}");
delay(500);
ESP.restart();
}
void handleAPIBandMap() {
if (!server.hasArg("plain")) { server.send(400); return; }
DynamicJsonDocument doc(512);
if (deserializeJson(doc, server.arg("plain")) != DeserializationError::Ok) {
server.send(400, "application/json", "{\"ok\":false}"); return;
}
for (int i = 0; i < BAND_COUNT; i++) {
String key = "r" + String(i);
if (doc.containsKey(key)) {
int r = doc[key].as<int>();
if (r >= 1 && r <= 4) bandMap[i].relay = r;
}
}
saveConfig();
server.send(200, "application/json", "{\"ok\":true}");
addLog("Band map updated");
Serial.println("[CFG] Band map saved");
}
void handleAPIResetBandMap() {
// Restore factory defaults
uint8_t def[BAND_COUNT] = {1,2,2,2,3,1,3,3,4,4,4};
for (int i = 0; i < BAND_COUNT; i++) bandMap[i].relay = def[i];
saveConfig();
server.send(200, "application/json", "{\"ok\":true}");
addLog("Band map reset to defaults");
}
void handleAPIAntNames() {
if (!server.hasArg("plain")) { server.send(400); return; }
DynamicJsonDocument doc(256);
if (deserializeJson(doc, server.arg("plain")) != DeserializationError::Ok) {
server.send(400); return;
}
JsonArray arr = doc["names"].as<JsonArray>();
int i = 0;
for (JsonVariant v : arr) {
if (i >= 4) break;
antName[i++] = v.as<String>();
}
saveConfig();
server.send(200, "application/json", "{\"ok\":true}");
addLog("Antenna names updated");
}
void handleAPISetting() {
if (!server.hasArg("key")) { server.send(400); return; }
String key = server.arg("key");
String val = server.arg("val");
if (key == "txlock") { txLockEnabled = (val == "true" || val == "1"); addLog("TX Lock: " + String(txLockEnabled ? "ON" : "OFF")); }
else if (key == "autorecon") { autoReconnect = (val == "true" || val == "1"); }
else if (key == "bootmode") { autoMode = (val == "auto"); }
else if (key == "delay") { bandSwitchDelay = val.toInt(); }
saveConfig();
server.send(200, "application/json", "{\"ok\":true}");
}
void handleAPIFlexConn() {
String mode = server.arg("mode");
String ip = server.arg("ip");
useManualIP = (mode == "manual");
if (useManualIP) flexManualIP = ip;
saveConfig();
// Trigger reconnect
radio.connected = false;
if (flexClient.connected()) flexClient.stop();
lastReconnectAttempt = 0;
server.send(200, "application/json", "{\"ok\":true}");
addLog("FLEX connection settings updated");
}
// ─────────────────────────────────────────────────────────
// REGISTER ALL ROUTES
// ─────────────────────────────────────────────────────────
void setupAPRoutes() {
server.on("/", HTTP_GET, handleAPRoot);
server.on("/scan", HTTP_GET, handleScan);
server.on("/connect", HTTP_POST, handleConnect);
server.onNotFound([]() { server.sendHeader("Location", "/"); server.send(302); });
}
void setupMainRoutes() {
server.on("/", HTTP_GET, handleRoot);
server.on("/api/state", HTTP_GET, handleAPIState);
server.on("/api/relay", HTTP_POST, handleAPIRelay);
server.on("/api/activate", HTTP_POST, handleAPIActivate);
server.on("/api/automode", HTTP_POST, handleAPIAutoMode);
server.on("/api/discover", HTTP_POST, handleAPIDiscover);
server.on("/api/reconnect", HTTP_POST, handleAPIReconnect);
server.on("/api/saveconfig", HTTP_POST, handleAPISaveConfig);
server.on("/api/clearlogs", HTTP_POST, handleAPIClearLogs);
server.on("/api/reboot", HTTP_POST, handleAPIReboot);
server.on("/api/resetwifi", HTTP_POST, handleAPIResetWifi);
server.on("/api/factoryreset", HTTP_POST, handleAPIFactoryReset);
server.on("/api/bandmap", HTTP_POST, handleAPIBandMap);
server.on("/api/resetbandmap", HTTP_POST, handleAPIResetBandMap);
server.on("/api/antnames", HTTP_POST, handleAPIAntNames);
server.on("/api/setting", HTTP_POST, handleAPISetting);
server.on("/api/flexconn", HTTP_POST, handleAPIFlexConn);
// OTA firmware upload via HTTP POST /api/ota
server.on("/api/ota", HTTP_POST,
// onComplete
[]() {
if (Update.hasError()) {
server.send(500, "application/json", "{\"ok\":false,\"error\":\"Update failed\"}");
} else {
server.send(200, "application/json", "{\"ok\":true}");
delay(500);
ESP.restart();
}
},
// onUpload (chunked handler)
[]() {
HTTPUpload& upload = server.upload();
if (upload.status == UPLOAD_FILE_START) {
Serial.printf("[OTA] HTTP upload: %s\n", upload.filename.c_str());
allRelaysOff();
if (!Update.begin(UPDATE_SIZE_UNKNOWN)) {
Update.printError(Serial);
}
} else if (upload.status == UPLOAD_FILE_WRITE) {
if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) {
Update.printError(Serial);
}
Serial.printf("[OTA] Written %u bytes\n", upload.totalSize);
} else if (upload.status == UPLOAD_FILE_END) {
if (Update.end(true)) {
Serial.printf("[OTA] Success: %u bytes. Restarting...\n", upload.totalSize);
addLog("OTA flash OK – restarting");
} else {
Update.printError(Serial);
addLog("OTA flash FAILED");
}
}
}
);
server.onNotFound([]() { server.sendHeader("Location","/"); server.send(302); });
}
// ─────────────────────────────────────────────────────────
// OTA SETUP
// ─────────────────────────────────────────────────────────
void setupOTA() {
ArduinoOTA.setHostname(MDNS_NAME);
ArduinoOTA.onStart([]() {
allRelaysOff();
Serial.println("[OTA] Update starting – all relays OFF");
});
ArduinoOTA.onEnd([]() { Serial.println("[OTA] Update complete"); });
ArduinoOTA.onProgress([](unsigned int p, unsigned int t) {
if (p % (t / 10) < 1000) Serial.printf("[OTA] %u%%\n", p * 100 / t);
});
ArduinoOTA.onError([](ota_error_t e) {
Serial.printf("[OTA] Error[%u]: ", e);
if (e == OTA_AUTH_ERROR) Serial.println("Auth Failed");
else if (e == OTA_BEGIN_ERROR) Serial.println("Begin Failed");
else if (e == OTA_CONNECT_ERROR) Serial.println("Connect Failed");
else if (e == OTA_RECEIVE_ERROR) Serial.println("Receive Failed");
else if (e == OTA_END_ERROR) Serial.println("End Failed");
});
ArduinoOTA.begin();
Serial.println("[OTA] Ready");
}
// ─────────────────────────────────────────────────────────
// BOOT BUTTON (hold 5s to reset WiFi)
// ─────────────────────────────────────────────────────────
void checkBootButton() {
if (digitalRead(BOOT_BTN_PIN) == LOW) {
if (!btnHeld) { btnHeld = true; btnHoldStart = millis(); }
else if (millis() - btnHoldStart > 5000) {
Serial.println("[BTN] Boot button held 5s → clearing WiFi credentials");
addLog("Boot button: WiFi reset triggered");
clearWiFiCreds();
delay(500);
ESP.restart();
}
} else {
btnHeld = false;
}
}
// ─────────────────────────────────────────────────────────
// OLED DISPLAY
// ─────────────────────────────────────────────────────────
void oledInit() {
Wire.begin(OLED_SDA, OLED_SCL);
if (!oled.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
Serial.println("[OLED] Not found – check wiring / address");
oledOK = false;
return;
}
oledOK = true;
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
// Splash
oled.setTextSize(1);
oled.setCursor(4, 0);
oled.println("ESP32 Ant Controller");
oled.setTextSize(2);
oled.setCursor(14, 20);
oled.println("Starting");
oled.setTextSize(1);
oled.setCursor(18, 50);
oled.println("Firmware v1.0.0");
oled.display();
Serial.println("[OLED] Initialised OK");
}
// Draw the main info screen:
// Line 1 (small): IP address OR "AP: 192.168.4.1" OR "Connecting..."
// Line 2 (BIG): Band (e.g. 20m )
// Line 3 (medium): ANT 1 / ANT 2 / ANT 3 / ANT 4 + antenna name
// Line 4 (small): FLEX status + TX state
void oledUpdate() {
if (!oledOK) return;
oled.clearDisplay();
// ── Row 1: IP ────────────────────────────────────────────
oled.setTextSize(1);
oled.setTextColor(SSD1306_WHITE);
String ipStr;
if (apMode) {
ipStr = "AP: 192.168.4.1";
} else if (WiFi.status() == WL_CONNECTED) {
ipStr = WiFi.localIP().toString();
} else {
ipStr = "WiFi connecting...";
}
// Centre the IP string (each char ~6px wide at size 1)
int ipX = max(0, (128 - (int)ipStr.length() * 6) / 2);
oled.setCursor(ipX, 0);
oled.print(ipStr);
// ── Row 2: Band – BIG text ────────────────────────────────
String bandDisp = (radio.band.length() > 0 && radio.band != "--") ? radio.band : "---";
// Size 3 = 18px tall, each char ~18px wide
int bSize = 3;
int bW = bandDisp.length() * 18;
if (bW > 120) { bSize = 2; bW = bandDisp.length() * 12; } // shrink if long
int bX = max(0, (128 - bW) / 2);
oled.setTextSize(bSize);
oled.setCursor(bX, 10);
oled.print(bandDisp);
// ── Row 3: Active antenna ──────────────────────────────────
// Find which relay is active
int activeRelay = -1;
for (int i = 0; i < 4; i++) { if (relayState[i]) { activeRelay = i; break; } }
// In auto mode derive from band map
if (activeRelay < 0 && autoMode && radio.band.length() > 0 && radio.band != "--") {
for (int i = 0; i < BAND_COUNT; i++) {
if (radio.band == bandMap[i].band) { activeRelay = bandMap[i].relay - 1; break; }
}
}
oled.setTextSize(2);
String antLine;
if (activeRelay >= 0) {
antLine = "ANT " + String(activeRelay + 1);
} else {
antLine = "ANT --";
}
int aX = max(0, (128 - (int)antLine.length() * 12) / 2);
oled.setCursor(aX, 36);
oled.print(antLine);
// ── Row 4: Antenna name + FLEX/TX status (small) ──────────
oled.setTextSize(1);
// Antenna name (truncate to fit)
if (activeRelay >= 0) {
String aName = antName[activeRelay];
if (aName.length() > 13) aName = aName.substring(0, 12) + ".";
int nX = max(0, (128 - (int)aName.length() * 6) / 2);
oled.setCursor(nX, 54);
oled.print(aName);
} else {
// Show FLEX connection status when no antenna active
String status = radio.connected ? ("FLEX " + radio.ip) : "FLEX disconnected";
if (status.length() > 21) status = status.substring(0, 20);
oled.setCursor(0, 54);
oled.print(status);
}
// TX indicator top-right corner
if (radio.txState == "TRANSMITTING") {
oled.fillRect(104, 0, 22, 9, SSD1306_WHITE);
oled.setTextColor(SSD1306_BLACK);
oled.setCursor(106, 1);
oled.print("TX");
oled.setTextColor(SSD1306_WHITE);
}
oled.display();
}
void setup() {
Serial.begin(115200);
delay(400);
Serial.println();
oledInit();
Serial.println("╔══════════════════════════════════════╗");
Serial.println("║ REM-Relay v1.0.0 ║");
Serial.println("║ Remote FLEX Radio Relay Controller ║");
Serial.println("╚══════════════════════════════════════╝");
// Relay pins – all OFF at boot
for (int i = 0; i < 4; i++) {
pinMode(relayPins[i], OUTPUT);
digitalWrite(relayPins[i], HIGH); // ACTIVE LOW
relayState[i] = false;
}
Serial.println("[GPIO] Relay pins configured (all OFF)");
// Boot button
pinMode(BOOT_BTN_PIN, INPUT_PULLUP);
bootMs = millis();
// Load saved config
loadConfig();
Serial.println("[CFG] autoMode=" + String(autoMode) + " txLock=" + String(txLockEnabled));
// ── WiFi ──────────────────────────────────────────────
if (!loadWiFiCreds() || wifiSSID.length() == 0) {
// ── AP MODE (first boot / no credentials) ──────────
apMode = true;
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, AP_PASS);
String apIP = WiFi.softAPIP().toString();
// Show AP info on OLED
if (oledOK) {
oled.clearDisplay();
oled.setTextSize(1); oled.setCursor(4,0); oled.print("WiFi Setup Mode");
oled.setTextSize(1); oled.setCursor(0,14); oled.print("SSID:");
oled.setCursor(0,24); oled.print("REM-Relay");
oled.setCursor(0,36); oled.print("Pass: 12345678");
oled.setCursor(0,50); oled.print("IP: "+apIP);
oled.display();
}
Serial.println("╔══════════════════════════════════════╗");
Serial.println("║ FIRST BOOT – AP MODE ║");
Serial.println("║ SSID: REM-Relay ║");
Serial.println("║ Password: 12345678 ║");
Serial.println("║ Open: http://" + apIP + " ║");
Serial.println("╚══════════════════════════════════════╝");
addLog("AP Mode started – SSID: REM-Relay");
setupAPRoutes();
server.begin();
Serial.println("[HTTP] AP web server started");
return; // Don't do anything else until WiFi is configured
}
// ── STA MODE ──────────────────────────────────────────
apMode = false;
WiFi.mode(WIFI_STA);
WiFi.setHostname(MDNS_NAME);
WiFi.begin(wifiSSID.c_str(), wifiPass.c_str());
Serial.println("[WIFI] Connecting to: " + wifiSSID);
Serial.print("[WIFI] ");
unsigned long wt = millis();
while (WiFi.status() != WL_CONNECTED && millis() - wt < 20000) {
delay(400);
Serial.print(".");
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
Serial.println("╔══════════════════════════════════════╗");
Serial.println("║ WIFI CONNECTED ║");
Serial.println("║ SSID: " + WiFi.SSID() + String(24 - WiFi.SSID().length(), ' ') + "║");
Serial.println("║ IP: " + WiFi.localIP().toString() + String(24 - WiFi.localIP().toString().length(), ' ') + "║");
Serial.println("║ RSSI: " + String(WiFi.RSSI()) + " dBm" + String(19 - String(WiFi.RSSI()).length(), ' ') + "║");
Serial.println("║ Web: http://" + WiFi.localIP().toString() + String(17 - WiFi.localIP().toString().length(), ' ') + "║");
Serial.println("║ mDNS: http://rem-relay.local ║");
Serial.println("╚══════════════════════════════════════╝");
addLog("WiFi connected: " + WiFi.SSID() + " → " + WiFi.localIP().toString());
oledUpdate(); // Show new IP immediately
} else {
Serial.println("[WIFI] Connection FAILED – falling back to AP mode");
addLog("WiFi FAILED – entering AP mode");
apMode = true;
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, AP_PASS);
setupAPRoutes();
server.begin();
return;
}
// mDNS
if (MDNS.begin(MDNS_NAME)) {
MDNS.addService("http", "tcp", 80);
Serial.println("[mDNS] rem-relay.local registered");
}
// OTA
setupOTA();
// HTTP routes
setupMainRoutes();
server.begin();
Serial.println("[HTTP] Web server started on port 80");
// FLEX connection
if (useManualIP && flexManualIP.length() > 6) {
Serial.println("[FLEX] Using manual IP: " + flexManualIP);
connectToFlex(flexManualIP);
} else if (radio.ip.length() > 6) {
Serial.println("[FLEX] Reconnecting to last known IP: " + radio.ip);
connectToFlex(radio.ip);
} else {
Serial.println("[FLEX] No IP known – starting discovery");
startDiscovery();
}
// Initial relay state
if (autoMode) {
Serial.println("[RELAY] Auto mode active – waiting for band data");
}
addLog("REM-Relay ready – v" + String(FW_VERSION));
Serial.println("[BOOT] Setup complete");
}
// ─────────────────────────────────────────────────────────
// LOOP
// ─────────────────────────────────────────────────────────
void loop() {
// AP mode – only handle web server + button
if (apMode) {
server.handleClient();
checkBootButton();
if (millis() - lastOledUpdate > 3000) { lastOledUpdate = millis(); oledUpdate(); }
return;
}
// Normal STA mode
server.handleClient();
ArduinoOTA.handle();
checkBootButton();
// MDNS runs automatically on ESP32 - no update() needed
// ── UDP Discovery ────────────────────────────────────
if (discovering) {
checkDiscovery();
return; // Don't try TCP while discovering
}
// ── FLEX TCP Read loop ───────────────────────────────
if (radio.connected && flexClient.connected()) {
// Read all available lines
while (flexClient.available()) {
String line = flexClient.readStringUntil('\n');
if (line.length() > 0) {
parseFlexMessage(line);
}
}
// Keepalive ping every 15s (just check connection alive)
static unsigned long lastPing = 0;
if (millis() - lastPing > 15000) {
lastPing = millis();
// SmartSDR doesn't need explicit ping, but we verify connection
if (!flexClient.connected()) {
radio.connected = false;
addLog("FLEX connection lost (keepalive)");
Serial.println("[FLEX] Connection lost");
}
}
} else {
// Connection dropped
if (radio.connected) {
radio.connected = false;
addLog("FLEX disconnected");
Serial.println("[FLEX] Disconnected");
}
// Auto-reconnect
if (autoReconnect && millis() - lastReconnectAttempt > RECONNECT_DELAY) {
lastReconnectAttempt = millis();
Serial.println("[FLEX] Attempting reconnect…");
addDebug("Auto-reconnect attempt #" + String(reconnectCount + 1));
if (useManualIP && flexManualIP.length() > 6) {
connectToFlex(flexManualIP);
} else if (radio.ip.length() > 6) {
connectToFlex(radio.ip);
} else {
startDiscovery();
}
}
}
// ── WiFi watchdog ────────────────────────────────────
static unsigned long lastWiFiCheck = 0;
if (millis() - lastWiFiCheck > 30000) {
lastWiFiCheck = millis();
if (WiFi.status() != WL_CONNECTED) {
Serial.println("[WIFI] Reconnecting…");
WiFi.reconnect();
addLog("WiFi reconnect triggered");
}
}
// ── OLED refresh every 2s ───────────────────────────
if (millis() - lastOledUpdate > 2000) {
lastOledUpdate = millis();
oledUpdate();
}
// ── Serial status every 30s ──────────────────────────
static unsigned long lastStatusPrint = 0;
if (millis() - lastStatusPrint > 30000) {
lastStatusPrint = millis();
Serial.println("─────────────────────────────────");
Serial.println("[STATUS] Uptime: " + String(millis()/1000) + "s");
Serial.println("[STATUS] WiFi: " + WiFi.SSID() + " (" + WiFi.localIP().toString() + ")");
Serial.println("[STATUS] FLEX: " + String(radio.connected ? "Connected" : "Disconnected") + " @ " + radio.ip);
Serial.println("[STATUS] Band: " + radio.band + " / " + radio.frequency + " MHz");
Serial.println("[STATUS] TX: " + radio.txState);
Serial.println("[STATUS] Relays: " +
String(relayState[0]?1:0)+String(relayState[1]?1:0)+String(relayState[2]?1:0)+String(relayState[3]?1:0));
Serial.println("[STATUS] FreeHeap: " + String(ESP.getFreeHeap()/1024) + " KB");
Serial.println("─────────────────────────────────");
}
}