Code

/*

 ============================================================

  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 &mdash; 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 &amp; 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 &amp; 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 &amp; 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> &nbsp;·&nbsp; ESP32 240 MHz &nbsp;·&nbsp; <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 &amp; 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&quot; OLED: IP / Band / Antenna / TX state</div>

          <div style="color:#3fb950">✅ Config backup &amp; restore (JSON download)</div>

          <div style="color:#3fb950">✅ OTA firmware update via web browser</div>

          <div style="color:#3fb950">✅ First-boot WiFi scan &amp; 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("─────────────────────────────────");

  }

}