Close
Skip to content

EACP Code v51.7

//****************************************************************************
 * ElectroKev Atomic Clock  
 * Author: Kevin Davy ([email protected])  
 * © 2025 - All Rights Reserved  
 *
 * Description:  
 * - Pulse-modulated clock synchronized to an external 10 MHz pulse.  
 * - Achieves fine pulse manipulation with accuracy up to 1 microsecond  
 *   per 24 hours for precise timekeeping.  
 * - Segregated NIST time reference for synchronization.  
 * - Manual controls for time and date with pulse injection or removal.  
 * - Rotary encoder used to adjust daily time alteration rate,  
 *   with automatic calculation for microseconds per pulse.  
 * - WiFi connectivity enabled for remote monitoring and control.  
 * - Continuous crystal speed monitoring to debug drift.  
 * - Web interface providing full control over all major clock functions  
 *   via server handlers.  
 * - Advanced fault detection for invalid pulse readings,  
 *   with automatic pulse removal and logging of debug data.  
 * - Regulated and finely tuned power design with individual power supplies  
 *   for stable performance.  
 * - OTA updates for seamless firmware updates over the air.  
 * - Dual-core configuration with timekeeping processes separate from other  
 *   resource-intensive functions for enhanced performance.  
 * - Pulse monitoring inputs configured on high-priority processor interrupts  
 *   for accurate counting.  
 * - Globally accessible NTP time server running on port 123,  
 *   providing highly accurate time synchronization.  
 * - Web based serial console with self monitoring error reporting.  
 *   providing real time debugging and self correction.  
 *
 ***************************************************************************/


/* RUN EVENTS ON CORE 0
   RUN ESP32 ON CORE 1 
   RUN PULSEPROCESSBUFFER ON CORE 1
   RUN NTP REQUEST SERVER ON 1 */


const float versionNumber = 51.8;

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include  //start this back up

WiFiUDP ntpUDP; //start this back up

//network stuff
const char *ssid = SSID;
const char *password = PASSWORD;
const char *buildDate = __DATE__;
const char *buildTime = __TIME__;

// Set the static IP details
IPAddress local_ip(192, 168, 1, 172);  //local ip
IPAddress gateway(192, 168, 1, 1);     // gatewat
IPAddress subnet(255, 255, 255, 0);    // subnet

AsyncWebServer server(8080);
Preferences preferences;

// --- NTP Configuration ---
const char *ntpServer = "pool.ntp.org";  // NTP server
const long gmtOffset_sec = 0;            // Offset for UTC
const int daylightOffset_sec = 0;        // No daylight savings time

const int NTP_PORT =123;

// --- Pin Configurations ---
#define ROTARY_CLK 26             // Rotary encoder clock pin
#define ROTARY_DT 27              // Rotary encoder data pin
#define ROTARY_SW 13              // Rotary encoder switch pin
#define adjustHoursPin 16         // Button pin for adjusting hours
#define adjustMinutesPin 17       // Button pin for adjusting minutes
#define adjustSecondsPin 5        // Button pin for adjusting seconds
#define adjustMicroSecondUp 18    // Button pin for increasing microseconds
#define adjustMicroSecondDown 19  // Button pin for decreasing microseconds
#define syncNTC 23
#define pulsePin 32  // Pulse pin input for counting pulses
#define BUFFER_SIZE 50


// --- Global Objects ---
UnixTime stamp(0);
LiquidCrystal_I2C lcd(0x27, 20, 4);

// --- Time Variables ---
double microSeconds = 0;
double displayMicroSeconds;
double microChange = 0;
float rotaryChangeValue = 0.1;  // Rotary encoder adjustment step
float timeAdjust = 0;
double pulseDurationBetweenRisingEdges;  //changed from float
double invalidPulse;
double averagePDBRE;                     //changedfrom float
double totalPDBRE;     
double totalPDBREAC=100000;         //changed from float
long long espRuntime=0; //long to double
long runtimeSeconds = 0; //long to double
long runtimeOneMinute=0;
long runtimeHours=0;
const int crystalCorrection = 0;  // Crystal correction factor
int adjustmentsMade;
double xtalFreq; //float to double
double totalXtalFreq; //float to double
double avgXtalFreq; //float to double
int lastUpdate = 0;
String timeNow;
unsigned long lastMillis;

//fault variables
int fault = 0;
int faultTime;
bool generalFault=true;
String status="FAULT";
String faultCode ="Fault: Unexpected ReStart ";
String flagOtherMessage=" ";
double faultyPDBRE;
bool flagInvalidPulse;
bool flagTimeDrift;
bool flagUnexpectedRestart=true;
bool flagNTPReceived;
bool flagNTPSync;
bool flagVisitor;
bool flagStackSize;
bool flagOther;



// --- Circular Buffer Variables ---
//volatile double currentTime;  
volatile double currentTime; // uint to double
volatile double nextHead; // uint to double
volatile double pulseTimes[BUFFER_SIZE]; // was double then long now int
volatile uint32_t bufferHead = 0;
volatile uint32_t bufferTail = 0;
double lastRisingEdgeTime = 0;
int pulseCount = 0;
double globalPulseCount = 0;

// --- Date Setting  ---
bool dateSetting = 0;

// --- Time Variables ---
int32_t nistTime;
int32_t unix;
double differenceTime; //int to double
double lastNistUpdate;
double bootupTime;

// --- General Variables ---
int visitorsCount;
int freeMemory = 0;
String clientIP;
double stackSize;
long ntpRequests;


// --- Interrupt Service Routine (ISR) ---
IRAM_ATTR void pulseISR() {
 currentTime=esp_timer_get_time();
  nextHead = (bufferHead + 1) % BUFFER_SIZE;
  if (nextHead != bufferTail) {  // Check if the buffer is not full
    pulseTimes[bufferHead] = currentTime;
    bufferHead = nextHead;
  }  
}

// --- Setup Function ---
void setup() {
    Serial.begin(115200);

    // --- WiFi and OTA Setup ---
    setupWifi();  // Starts WiFi and web server (runs on Core 0)

    Serial.println("Starting OTA...");
    ArduinoOTA.begin();
    Serial.println("OTA Ready!");
    ArduinoOTA.setPassword(nullptr);  // Disable password

    // --- Rotary Encoder Pins Setup ---
    pinMode(ROTARY_CLK, INPUT);
    pinMode(ROTARY_DT, INPUT);
    pinMode(ROTARY_SW, INPUT_PULLUP);

    // --- Pulse Pin Setup ---
    pinMode(pulsePin, INPUT);
    attachInterrupt(digitalPinToInterrupt(pulsePin), pulseISR, FALLING);

    // --- LCD Initialization ---
    lcd.init();
    lcd.begin(20, 4);
    lcd.backlight();

    // --- Button Pins Setup ---
    pinMode(adjustHoursPin, INPUT_PULLUP);
    pinMode(adjustMinutesPin, INPUT_PULLUP);
    pinMode(adjustSecondsPin, INPUT_PULLUP);
    pinMode(adjustMicroSecondDown, INPUT_PULLUP);
    pinMode(adjustMicroSecondUp, INPUT_PULLUP);
    pinMode(syncNTC, INPUT_PULLUP);

    // --- Load Stored Data ---
    loadFromEEPROM();

    // --- Sync Time from NTP ---
    syncWithNTP();
    stamp.getDateTime(unix);

    // --- Task: Process Pulse Buffer ---
    xTaskCreatePinnedToCore(
        [](void*) {
            while (true) {
                processPulseBuffer();
                checkStack();
                vTaskDelay(pdMS_TO_TICKS(100));  // Delay to allow CPU time for other tasks
            }
        },
        "processPulseBuffer",  // Task name
        4000,                 // Stack size
        NULL,                  // No parameters
        1,                     // Priority (higher than default)
        NULL,                  // Task handle
        1                      // Pin to Core 1 ( was 0)
    );

    // --- Task: Process NTP Requests --- //start this back up
    xTaskCreatePinnedToCore(
        taskProcessNTPRequests,  // Task function
        "ProcessNTPRequests",    // Task name
        4096,                   // Stack size
        NULL,                    // Task parameters
        1,                       // Priority (higher than pulse processing)
        NULL,                    // Task handle
        1                        // Core 1
    );

    // --- Startup Confirmation ---
    Serial.println("Running");
    flagUnexpectedRestart = true;
    faultHandler();
}

// --- Main Loop ---
void loop() {
  handleButtons();        // Check and process button inputs
  handleRotaryEncoder();  // Handle rotary encoder inputs
  adjustmentRoutine();    // Adjust time if microseconds overflow
  ArduinoOTA.handle();
  vTaskDelay(100);
}


void taskProcessNTPRequests(void *pvParameters) { //start this back up
  // Run indefinitely with a 1-second delay
  while (true) {
    processNTPRequests();  // Call the function to process NTP requests
    vTaskDelay(pdMS_TO_TICKS(200));  // limit function execution frequency
  }
}


void faultCheck(){
  generalFault = flagUnexpectedRestart || flagTimeDrift || flagInvalidPulse; // Set generalFault
  status = generalFault ? "FAULT" : "OK";  // Update status
}


void faultHandler() {
    // Handle unexpected restart
    if (flagUnexpectedRestart) {
        esp_reset_reason_t resetReason = esp_reset_reason();
        String resetReasonText;

        switch (resetReason) {
            case ESP_RST_POWERON: resetReasonText = "POWERON"; break;
            case ESP_RST_BROWNOUT: resetReasonText = "BROWNOUT"; break;
            case ESP_RST_SW: resetReasonText = "SW"; break;
            case ESP_RST_EXT: resetReasonText = "EXT"; break;
            case ESP_RST_PANIC: resetReasonText = "MEMPANIC"; break;
            case ESP_RST_WDT: resetReasonText = "WATCHDOG"; break;
            default: resetReasonText = "UNKNOWN"; break;
        }

        faultCode = timeNow + "   Fault: Unexpected Restart ESP_RST_" + resetReasonText + 
                    " " + String(espRuntime) + " Sec\n" + faultCode;
        generalFault = true;
        flagUnexpectedRestart = false;
    }

    // Handle specific faults
    if (flagTimeDrift) {
        faultCode = timeNow + "   Fault: Time Drift -> " + String(differenceTime) + "\n" + faultCode;
        generalFault = true;
        flagTimeDrift = false;
    }
    if (flagInvalidPulse) {
        faultCode = timeNow + ".  Fault: Invalid Pulse (" + String(invalidPulse) + ")\n" + faultCode;
        generalFault = true;
        flagInvalidPulse = false;
    }

    // Log events (without triggering generalFault)
    if (flagNTPReceived) {
        faultCode = timeNow + "   NTP Request Received from " + clientIP + "\n" + faultCode;
        ntpRequests++;
        flagNTPReceived = false;
    }
    if (flagNTPSync) {
        faultCode = timeNow + "   NTP Successfully Synced\n" + faultCode;
        flagNTPSync = false;
    }
    if (flagVisitor) {
        faultCode = timeNow + "   Web User Logged In [IP:" + clientIP + "]\n" + faultCode;
        flagVisitor = false;
    }
    if (flagStackSize) {
        faultCode = timeNow + "  WARNING: StackSize Remaining: " + String(stackSize) + " bytes\n" + faultCode;
        flagStackSize = false;
    }
    if (flagOther) {
        faultCode = timeNow + "   Message: " + flagOtherMessage + "\n" + faultCode;
        flagOtherMessage.clear();
        flagOther = false;
    }


    // Save fault status and update system status
    status = generalFault ? "FAULT" : "OK";
    saveToEEPROM();
}


void customMessage(String cm){
  flagOther=true;
  flagOtherMessage=cm;
  faultHandler();
}


void processPulseBuffer() {
    while (bufferTail != bufferHead) {
        // Disable interrupts temporarily to process the pulse
        noInterrupts();
        double pulseTime = pulseTimes[bufferTail];
        bufferTail = (bufferTail + 1) % BUFFER_SIZE;
        pulseDurationBetweenRisingEdges = (pulseTime - lastRisingEdgeTime) + crystalCorrection;
        lastRisingEdgeTime = pulseTime;
        interrupts();  // Re-enable interrupts

        // Initialize variables at startup
        if (runtimeSeconds == 0) {
            globalPulseCount = 9;
            totalXtalFreq = 90;
            fault = 0;
        }

        // Validate the pulse duration
        if (pulseDurationBetweenRisingEdges >= 50000 && pulseDurationBetweenRisingEdges <= 150000) {
            pulseCount++;
            // Calculate crystal frequency 
            xtalFreq = 1000000 / pulseDurationBetweenRisingEdges;
        } else {
            invalidPulse = pulseDurationBetweenRisingEdges;
            fault++;
            flagInvalidPulse = true;
            faultHandler();
        }

        
       

        // Update crystal frequency tracking
        if (runtimeSeconds > 0) {
            totalXtalFreq += xtalFreq;
            avgXtalFreq = totalXtalFreq / globalPulseCount;

        }

        globalPulseCount++;

        // Perform 1-minute tasks
        if (runtimeOneMinute >= 60) {
            nistUpdate();
            lastNistUpdate = nistTime;
            runtimeOneMinute = 0;
            
        }

        // Reset after 1 hour
        if (runtimeSeconds >= 3600) {
            totalPDBREAC = 0;
            totalPDBRE = 0;
            runtimeSeconds = 0;
            runtimeHours++;
            averagePDBRE = 0;
            totalPDBREAC=100000;
            avgXtalFreq = 0;
            totalXtalFreq = 0;
            globalPulseCount = 0;
            String allOK="| Self Check: "+status;
            customMessage(allOK);
        }

        // Process accumulated pulses (every 10 pulses)
        if (pulseCount >= 10) {
            if (runtimeSeconds > 0) {
                totalPDBREAC += pulseDurationBetweenRisingEdges;  
                totalPDBRE = ((runtimeSeconds + 1) * 100000) - totalPDBREAC;
                averagePDBRE = totalPDBREAC / (runtimeSeconds + 1);
            }
            // Track system runtime
            runtimeSeconds++;
            pulseCount = 0;
            espRuntime++;
            runtimeOneMinute++;
            microSeconds += (microChange * 10);
            freeMemory = ESP.getFreeHeap();

            timeAdjustCorrections();

            // Update time and display once per successful 10-pulse cycle
            updateTime();   
            updateDisplay();
        }
        
    }
}

void timeAdjustCorrections(){
   // Adjust display for remaining microseconds per hour
            if (timeAdjust != 0) {
                if (microSeconds < 0) {
                    displayMicroSeconds = round((100000 + microSeconds) / (10 * abs(microChange) * 3600) * 1000) / 1000;
                } else if (microSeconds > 0) {
                    displayMicroSeconds = round((100000 - microSeconds) / (10 * microChange * 3600) * 1000) / 1000;
                }

                // Update microseconds adjustment
                microChange = ((timeAdjust * 1000000) / 864000);

                // Correct very small timeAdjust values close to 0
                if (abs(timeAdjust) < 0.1) {
                    timeAdjust = 0.0;
                    microSeconds = 0;
                    microChange = 0;
                    displayMicroSeconds = 0;
                }
            } 
}

void checkDifference() {
    // Check if 1000 milliseconds (1 second) have passed
    if (millis() - lastMillis >= 1000) {
        lastMillis += 1000;  // Maintain consistent timing to avoid drift

        nistTime++;  // Increment NTP time
        differenceTime = unix - nistTime;

        // Print time debug info
        /*Serial.print(nistTime);
        Serial.print(" | ");
        Serial.println(unix);*/

        // Set fault code if time sync drifts beyond limit
        if (abs(differenceTime) >= 3) {
            flagTimeDrift = true;
            faultHandler();
            flagTimeDrift=false;
            customMessage("Attempting Self Repair...");
            syncWithNTP();  
            
        }

       
    }
}


void processNTPRequests() {
    int packetSize = ntpUDP.parsePacket();
    if (packetSize > 0) {
        Serial.println("NTP request received");

        if (packetSize > 48) {  // Prevent overflow
            Serial.println("NTP request too large!");
            customMessage("NTP request too large");
            return;
        }

        byte packetBuffer[48]; // NTP request packet
        ntpUDP.read(packetBuffer, packetSize); // Read request

        // Ensure valid UDP connection before responding
        if (ntpUDP.remotePort() == 0) {
            Serial.println("Invalid remote port!");
            customMessage("Invalid remote port");
            return;
        }

        // Set up the NTP response structure
        memset(packetBuffer, 0, 48);
        packetBuffer[0] = 0b00100100; // LI, Version, Mode

        // Ensure `unix` is properly defined
        unsigned long currentEpoch = unix;  // Make sure `unix` is valid

        // Convert to NTP timestamp format
        unsigned long ntpSeconds = currentEpoch + 2208988800UL;
        packetBuffer[40] = (ntpSeconds >> 24) & 0xFF;
        packetBuffer[41] = (ntpSeconds >> 16) & 0xFF;
        packetBuffer[42] = (ntpSeconds >> 8) & 0xFF;
        packetBuffer[43] = ntpSeconds & 0xFF;

        // Send response
        ntpUDP.beginPacket(ntpUDP.remoteIP(), ntpUDP.remotePort());
        ntpUDP.write(packetBuffer, 48);
        ntpUDP.endPacket();

        // Retrieve and print the IP address of the requesting client
        Serial.println(ntpUDP.remoteIP());

        // Avoid dynamic allocation with `toString()`
        char clientIP[16];
        ntpUDP.remoteIP().toString().toCharArray(clientIP, sizeof(clientIP));

        Serial.println("NTP response sent");
        flagNTPReceived = true;
        faultHandler();  
    }
}


void checkStack(){
  // Check stack size
        stackSize = uxTaskGetStackHighWaterMark(NULL);
        if (stackSize < 2000) {
            flagStackSize = true;
            faultHandler();
        }
}


/// --- Handle Rotary Encoder Inputs ---
void handleRotaryEncoder() {
  static int lastEncoderState = HIGH;
  int currentEncoderState = digitalRead(ROTARY_CLK);
  static unsigned long lastEncoderChangeTime = 0;
  const unsigned long debounceDelay = 80;  // Increased debounce delay

  // Check for debounce timing
  if ((millis() - lastEncoderChangeTime) > debounceDelay) {
    if (currentEncoderState != lastEncoderState) {
      // If the direction is different, increment or decrement the value by the desired rotaryChangeValue
      if (digitalRead(ROTARY_DT) != currentEncoderState) {
        timeAdjust += 0.1;  // Adjust by 0.1
      } else {
        timeAdjust -= 0.1;  // Adjust by -0.1
      }

      // Update the last change time
      lastEncoderChangeTime = millis();
    }
  }
  // Update the last encoder state
  lastEncoderState = currentEncoderState;
}

// --- Handle Button Inputs ---
void handleButtons() {
  static unsigned long lastButtonPressTime = 0;
  const unsigned long buttonDebounceInterval = 150;

  // Check for debounce interval
  if (millis() - lastButtonPressTime > buttonDebounceInterval) {

    // Toggle dateSetting if rotary switch is pressed
    if (digitalRead(ROTARY_SW) == LOW) {
      saveToEEPROM();
      dateSetting = !dateSetting;  // Toggle the value of dateSetting
    }

    // Adjust time or date based on dateSetting state
    if (!dateSetting) {
      // Time adjustment mode
      if (digitalRead(adjustHoursPin) == LOW) {
        unix += 3600;  // Add one hour
      } else if (digitalRead(adjustMinutesPin) == LOW) {
        unix += 60;  // Add one minute
      } else if (digitalRead(adjustSecondsPin) == LOW) {
        unix -= stamp.second;  // Reset seconds
        pulseCount = 0;
      } else if (digitalRead(adjustMicroSecondDown) == LOW) {
        pulseCount--;  // Decrease pulse count
      } else if (digitalRead(adjustMicroSecondUp) == LOW) {
        pulseCount++;  // Increase pulse count
      } else if (digitalRead(syncNTC) == LOW) {
        lcd.clear();
        lcd.print("WiFi Toggle Button Pressed");

        if (WiFi.status() == WL_CONNECTED) {
          lcd.print("Turning WiFi OFF...");
          WiFi.disconnect(true);
          WiFi.mode(WIFI_OFF);
        } else {
          lcd.print("Turning WiFi ON...");
          WiFi.mode(WIFI_STA);
          WiFi.begin(ssid, password);
        }

        delay(500);
        lcd.clear();
      }
    } else if (dateSetting) {
      // Date adjustment mode
      if (digitalRead(adjustHoursPin) == LOW) {
        unix += 86400;  // Add one day
      } else if (digitalRead(adjustMinutesPin) == LOW) {
        unix += 2592000;  // Add one month (needs refinement for proper month handling)
      } else if (digitalRead(adjustSecondsPin) == LOW) {
        unix += 31622400;  // Add one year
      } else if (digitalRead(adjustMicroSecondDown) == LOW) {
        unix = 1736521548;
      }
    }

    // Update date and time
    stamp.getDateTime(unix);

    // Update debounce timer
    lastButtonPressTime = millis();
  }
}

// --- Update Time ---
void updateTime() {
  unix++;
  stamp.getDateTime(unix);
  checkDifference();
  timeNow = "["+String(formatTime(stamp.hour) + ":" + formatTime(stamp.minute) + ":" + formatTime(stamp.second))+"]";
}

// --- Update Display ---
void updateDisplay() {

  if (dateSetting) {
    lcd.clear();
    lcd.setCursor(0, 0);
    lcd.print("Date Setting Mode");
    lcd.setCursor(0, 1);
    lcd.print("Adj:");
    lcd.print(timeAdjust, 1);
    lcd.print(" ");
    lcd.print(displayMicroSeconds);  // Display positive microseconds
    return;
  }

  // Display full time and date
  lcd.setCursor(0, 0);
  displayFullTimeDate();

  // Display UTC time
  lcd.setCursor(0, 1);
  lcd.print("UTC: ");
  lcd.print(unix),0;  // Display Unix timestamp
  lcd.setCursor(0, 2);
  lcd.print("NTP: ");
  lcd.print(nistTime),0;

  // Display calculated crystal frequency
  lcd.setCursor(0, 3);
  lcd.print(avgXtalFreq, 6);
  lcd.print(" MHz ");

  // Display pulse adjustments
  lcd.setCursor(14, 3);
  lcd.print("PA:");
  lcd.print(adjustmentsMade);
}

// --- Adjustment Routine ---
void adjustmentRoutine() {
  if (microSeconds >= 100000) {
    microSeconds = 0;
    pulseCount++;
    adjustmentsMade++;
  } else if (microSeconds <= -100000) {
    microSeconds = 0;
    pulseCount--;
    adjustmentsMade++;
  }
}


void loadFromEEPROM() {
  preferences.begin("clock", true);  // Open in read-only mode
  microChange = preferences.getInt("microChange", 0);  // Default value 0
  timeAdjust = preferences.getFloat("timeAdjust", 0);
  faultCode = preferences.getString("faultCode", faultCode);
  runtimeHours = preferences.getDouble("runtimeHours", 0);
  preferences.end();
  
}

void saveToEEPROM() {
  preferences.begin("clock", false);  // Open a namespace (false = read/write mode)
  preferences.putInt("microChange", microChange);
  preferences.putString("faultCode", faultCode);
  preferences.putFloat("timeAdjust", timeAdjust);
  preferences.putDouble("runtimeHours", runtimeHours);
  preferences.end();  // Close the Preferences storage
  
}

// --- Format Time ---
String formatTime(int timeValue) {
  return (timeValue < 10) ? "0" + String(timeValue) : String(timeValue);
}

void displayFullTimeDate() {
  // Get the current time and date from the Unix timestamp
  int currentYear = (stamp.year);      // Current year
  int currentMonth = (stamp.month);    // Current month (1-12)
  int currentDay = (stamp.day);        // Current day
  int currentHour = (stamp.hour);      // Current hour
  int currentMinute = (stamp.minute);  // Current minute
  int currentSecond = (stamp.second);  // Current second

  // Display time and date on the LCD
  lcd.print(currentDay);
  lcd.print("/");
  lcd.print(currentMonth);
  lcd.print("/");
  lcd.print(currentYear);

  // lcd.setCursor(0, 1);
  lcd.print(" ");
  lcd.print(formatTime(currentHour));
  lcd.print(":");
  lcd.print(formatTime(currentMinute));
  lcd.print(":");
  lcd.print(formatTime(currentSecond));
}

// --- Display Variables in Serial Monitor  ---
void displayVariables() {
  Serial.print(" RT: ");
  Serial.print(runtimeSeconds);
  Serial.print(" PC: ");
  Serial.print(pulseCount);
  Serial.print(" GPC: ");
  Serial.print(globalPulseCount);
  Serial.print(" PDBRE: ");
  Serial.print(pulseDurationBetweenRisingEdges);
  Serial.print(" unix: ");
  Serial.print(unix);
  Serial.print(" nistTime: ");
  Serial.print(nistTime);
  Serial.print(" dT: ");
  Serial.print(differenceTime);
  Serial.print(" xTalFreq: ");
  Serial.print(xtalFreq, 6);
  Serial.print(" totalXF: ");
  Serial.print(totalXtalFreq, 6);
  Serial.print(" avgXF: ");
  Serial.println(avgXtalFreq, 6);
}

void setupWifi() {
  // Begin WiFi connection
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.println("Connecting to WiFi...");
  }
  // Display WiFi connection details
  Serial.println("Connected to WiFi");
  Serial.println(WiFi.localIP());
  WiFi.setSleep(false);

  // Set up route handlers
  routeHandlers();

  // Start the server
  server.begin();
  Serial.println("Server started!");

  ntpUDP.begin(NTP_PORT); //start this back up
  Serial.println("NTP Server started on port 123");
}



void handleVariables(AsyncWebServerRequest *request) {
  // Create a JSON document (this is a fixed-size buffer to hold the JSON)
  StaticJsonDocument<1024> doc;

  // Fill the JSON document with data
  doc["unixTime"] = unix;
  doc["bootupTime"] = bootupTime;
  doc["nistTime"] = nistTime;
  doc["date"] = String(stamp.day) + "/" + String(stamp.month) + "/" + String(stamp.year);
  doc["time"] = formatTime(stamp.hour) + ":" + formatTime(stamp.minute) + ":" + formatTime(stamp.second);
  doc["displayMicroSeconds"] = displayMicroSeconds;
  doc["microChange"] = microChange;
  doc["pulseDurationBetweenRisingEdges"] = pulseDurationBetweenRisingEdges;
  doc["timeAdjust"] = timeAdjust;
  doc["differenceTime"] = differenceTime;
  doc["freeMemory"] = freeMemory;
  doc["averagePDBRE"] = String(averagePDBRE, 2);
  doc["runtimeSeconds"] = runtimeSeconds;
  doc["adjustmentsMade"] = adjustmentsMade;
  doc["xtalFreq"] = String(xtalFreq, 6);
  doc["avgXtalFreq"] = String(avgXtalFreq, 6);
  doc["fault"] = fault;
  doc["globalPulseCount"] = globalPulseCount;
  doc["totalPDBRE"] = totalPDBRE;
  doc["espRuntime"] = espRuntime;
  doc["lastNistUpdate"] = lastNistUpdate;
  doc["runtimeHours"] = runtimeHours;
  doc["faultCode"] = String(faultCode);
  doc["status"] = String(status);
  doc["stackSize"] = String(stackSize);
  doc["ntpRequests"] = String(ntpRequests);

  // Serialize the JSON document to a string
  String json;
  serializeJson(doc, json);

  // Send the JSON response
  request->send(200, "application/json", json);
}

void nistUpdate() {
  configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
  struct tm timeinfo2;
  if (!getLocalTime(&timeinfo2)) {
    return;
  }
  nistTime = (mktime(&timeinfo2));
}

void syncWithNTP() {
  visitorsCount = 0;

  // Configure NTP
  configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
  Serial.println("Connecting to NTP server...");

  // Wait for NTP sync
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo, 10000)) {  // Wait for up to 10 seconds
    Serial.println("Failed to get time from NTP server.");
    lcd.clear();
    lcd.print("NTP Sync Failed!");
    return;
  }

  // Convert to Unix timestamp and update time
  microSeconds = 0;  // Reset microsecond adjustments
  pulseCount = 0;
  unix = (mktime(&timeinfo));
  nistTime = (mktime(&timeinfo));
  lastNistUpdate = nistTime;
  bootupTime=unix;
  stamp.getDateTime(unix);
 
  

  // Debug Output
  Serial.println("NTP Sync Successful!");
  flagNTPSync=true;
  faultHandler();
  Serial.print("Unix Time: ");
  Serial.println(unix);

  // Update LCD
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print("NTP Sync OK!");
  lcd.setCursor(0, 1);
  lcd.print("Time Updated.");
  
}

void fullReset(){
    generalFault=false;
    fault = 0;
    flagInvalidPulse=false;
    flagUnexpectedRestart=false;
    flagTimeDrift=false;
    flagNTPReceived=false;
    flagNTPSync=false;
    flagVisitor=false;
    flagStackSize=false;
    flagOther=false;
    faultCode="Status: OK : StackSize = "+String(stackSize)+"\n"+"Connection Started(clock.electrokev.com): OK"+"\n"+"Server Started(Port 80): OK"+"\n"+"NTP Server Started(Port123): OK";
    totalPDBRE = 0;
    totalPDBREAC=100000;
    runtimeSeconds = 0;
    runtimeOneMinute=0;
    runtimeHours=0;
    espRuntime =0;
    averagePDBRE = 0;
    xtalFreq = 0;
    avgXtalFreq = 0;
    totalXtalFreq = 0;
    //pulseCount=0;
    globalPulseCount = 0;
    lcd.clear();
    lcd.setCursor(0, 0);
    lcd.print("Full Reset");
    saveToEEPROM();
}

void routeHandlers() {

  // Define route handlers
  server.on("/", HTTP_GET, handleRoot);
  server.on("/variables", HTTP_GET, handleVariables);

  // Synchronize time with NTP
  server.on("/syncNTP", HTTP_GET, [](AsyncWebServerRequest *request) {
    syncWithNTP();
    request->send(200, "text/plain", "NTP Sync Completed");
  });

  // Increase pulse count
  server.on("/microUP", HTTP_GET, [](AsyncWebServerRequest *request) {
    pulseCount++;
    lcd.clear();
    lcd.setCursor(0, 0);
    lcd.print("MicroUp");
    request->send(200, "text/plain", "Pulse Count Increased");
  });

  // Decrease pulse count
  server.on("/microDOWN", HTTP_GET, [](AsyncWebServerRequest *request) {
    pulseCount--;
    lcd.clear();
    lcd.setCursor(0, 0);
    lcd.print("MicroDown");
    request->send(200, "text/plain", "Pulse Count Decreased");
  });

  // Increase time adjust
  server.on("/timeAdjustINC", HTTP_GET, [](AsyncWebServerRequest *request) {
    timeAdjust += 0.1;
    lcd.clear();
    lcd.setCursor(0, 0);
    lcd.print("timeAdjust INC");
    request->send(200, "text/plain", "Time Adjust Increased");
  });

  // Decrease time adjust
  server.on("/timeAdjustDEC", HTTP_GET, [](AsyncWebServerRequest *request) {
    timeAdjust -= 0.1;
    lcd.clear();
    lcd.setCursor(0, 0);
    lcd.print("timeAdjust DEC");
    request->send(200, "text/plain", "Time Adjust Decreased");
  });

  // Get version number
  server.on("/getVersion", HTTP_GET, [](AsyncWebServerRequest *request) {
    String version = String(versionNumber, 1);
    request->send(200, "application/json", "{\"version\":\"" + version + "\"}");
  });

  // Lock data to EEPROM
  server.on("/lockEEPROM", HTTP_GET, [](AsyncWebServerRequest *request) {
    saveToEEPROM();
    lcd.clear();
    lcd.setCursor(0, 0);
    lcd.print("EEPROM Saved");
    request->send(200, "text/plain", "Saved to EEPROM");
  });

  // Reset buffer
  server.on("/bufferReset", HTTP_GET, [](AsyncWebServerRequest *request) {
 fullReset();
    request->send(200, "text/plain", "Buffer Reset");
  });

  // Restart the ESP32
  server.on("/neverPress", HTTP_GET, [](AsyncWebServerRequest *request) {
    ESP.restart();
    request->send(200, "text/plain", "Never Press");
  });
}

void handleRoot(AsyncWebServerRequest *request) {
  // Increment visitor count on each webpage load
  clientIP=request->client()->remoteIP().toString();
  visitorsCount++;
  flagVisitor=true;
  faultHandler();

  String html = R"rawliteral(


    
    ElectroKev Atomic Clock EACP
    
body {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 100vh;
    margin: 0;
    background: linear-gradient(135deg, #1f1f1f, #333);
    color: #e0e0e0;
    font-family: 'Arial', sans-serif;
}

.time {
    font-size: 4rem;
    font-weight: bold;
    text-shadow: 0 0 5px #0f0, 0 0 10px #00ff00;
    margin-bottom: 20px;
    color: #00ff00;
}

.container {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 20px;
    background: #222;
    color: #fff;
    border-radius: 10px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
    max-width: 800px;
    margin: 20px auto;
    text-align: center;
    width: 100%;
}

.variables {
    display: grid;
    grid-template-columns: repeat(3, 2fr);
    gap: 10px;
    width: 100%;
    max-width: 800px;
    text-align: left;
    padding: 5px;
}

.variable {
    font-size: 1rem;
    padding: 12px 20px;
    border-radius: 12px;
    background: rgba(255, 255, 255, 0.1);
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4);
}

.version-container {
    margin-top: 20px;
    display: flex;
    justify-content: center;
    gap: 10px;
    font-size: 1.1rem;
}

.version {
    color: #fff;
    font-weight: bold;
}

.last-updated {
    color: #00ff00;
    font-weight: normal;
}

.ntp {
    color: orange;
    font-weight: normal;
}

.footer {
    margin-top: 30px;
    font-size: 1.2rem;
    text-align: center;
    color: #ccc;
}

.footer a,
.mainlinks a {
    color: #00ff00;
    text-decoration: none;
    font-weight: bold;
}

.footer a:hover {
    text-decoration: underline;
}

.description {
    font-size: 1.2rem;
    text-align: left;
    color: #e0e0e0;
    padding: 20px;
    background: rgba(0, 0, 0, 0.7);
    border-radius: 15px;
    box-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);
    margin-top: 30px;
    width: 100%;
    max-width: 800px;
}

.description h3 {
    font-size: 1.5rem;
    font-weight: bold;
    color: #ffdd00;
}

.admin-controls {
    text-align: center;
    margin: 20px;
}

.serial-container {
    margin-top: 30px;
    width: 100%;
    max-width: 800px;
    height: 300px;
    background-color: #1e1e1e;
    color: #00ff00;
    border-radius: 10px;
    padding: 10px;
    overflow-y: scroll;
    box-shadow: 0px 0px 15px rgba(0, 255, 0, 0.1);
    font-size: 22px;
    white-space: pre-wrap;
}

.serial-output {
    font-family: 'Courier New', monospace;
    font-size: 10px;
    margin: 0;
    padding: 0;
    line-height: 1.5;
}

.serial-output div {
    padding: 2px;
}

.timestamps {
    color: #ffdd00;
    font-weight: bold;
}

.header {
    font-size: 28px;
    font-weight: bold;
    margin-bottom: 5px;
}

.header2 {
    font-size: 16px;
    font-weight: normal;
    color: #ffa500;
}

.subheader {
    font-size: 16px;
    color: #ccc;
    margin-bottom: 15px;
}

.version-container,
.ntp-container,
.bootup-container {
    margin-top: 10px;
    font-size: 16px;
}

.status-label {
    font-weight: bold;
    margin-left: 10px; /*this one for main serial output */
    color: #ffffff;
}

.status {
    font-weight: bold;
}

.status[data-status="OK"] {
    color: #00ff00;
}

.status[data-status="FAULT"] {
    color: #ff0000;
}

/* Responsive Adjustments */
@media (max-width: 600px) {
    .time { font-size: 6rem; }
    .variable { font-size: 14px; }
    .header, .header2 { font-size: 1rem; }
    .footer { font-size: 1.5rem; }
    .description { font-size: 1rem; }
}

@media (max-width: 1024px) {
    .time { font-size: 6rem; }
    .variable { font-size: 24px; }
    .header, .header2 { font-size: 3rem; }
    .footer { font-size: 1.5rem; }
    .description { font-size: 1rem; }
}


button {
    font-family: 'Arial', sans-serif;
    font-size: 0.8rem;
    padding: 12px 20px;
    border: none;
    border-radius: 8px;
    background: rgba(0, 255, 0, 0.2); /* Transparent green effect */
    color: #00ff00;
    cursor: pointer;
    transition: all 0.3s ease-in-out;
    backdrop-filter: blur(8px); /* Glass effect */
    border: 1px solid rgba(0, 255, 0, 0.4);
    box-shadow: 0 4px 10px rgba(0, 255, 0, 0.2);
    text-transform: uppercase;
    font-weight: bold;
    letter-spacing: 1px;
    width: 100%; /* Ensures uniform width */
    max-width: 200px; /* Prevents stretching */
}

/* Hover Effect */
button:hover {
    background: rgba(0, 255, 0, 0.3);
    box-shadow: 0 6px 15px rgba(0, 255, 0, 0.4);
    transform: translateY(-2px);
}

/* Active Button (When Clicked) */
button:active {
    background: rgba(0, 255, 0, 0.4);
    box-shadow: 0 2px 5px rgba(0, 255, 0, 0.5);
    transform: scale(0.98);
}

/* Disabled Button */
button:disabled {
    background: rgba(255, 255, 255, 0.2);
    color: rgba(255, 255, 255, 0.5);
    border: 1px solid rgba(255, 255, 255, 0.3);
    cursor: not-allowed;
    box-shadow: none;
}

/* Focus Effect */
button:focus {
    outline: none;
    box-shadow: 0 0 8px rgba(0, 255, 0, 0.6);
}

/* Button Container & Grid */
.button-container {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 10px;
    margin-top: 20px;
}

/* Fixed Grid for Buttons */
.button-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); /* Ensures proper wrapping */
    gap: 10px;
    justify-items: center;
    width: 100%; /* Makes sure the grid expands correctly */
    max-width: 600px; /* Prevents excessive stretching */
    margin: 0 auto; /* Centers the grid */
}

/* Mobile-Friendly Fix */
@media (max-width: 800px) {
    button {
        font-size: 1rem;
        padding: 14px;
        width: 100%;
        max-width: 250px;
    }

    .button-grid {
        grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); /* Adjusts for smaller screens */
    }
}
    
    

function syncNTP() {
    fetch('/syncNTP')
      .then(response => response.text())
      .then(data => console.log(data))  // Log the response
      .catch(error => console.error('Error:', error));
}

function microDOWN() {
    fetch('/microDOWN')
      .then(response => response.text())
      .then(data => console.log(data))
      .catch(error => console.error('Error:', error));
}

function microUP() {
    fetch('/microUP')
      .then(response => response.text())
      .then(data => console.log(data))
      .catch(error => console.error('Error:', error));
}

function timeAdjustINC() {
    fetch('/timeAdjustINC')
      .then(response => response.text())
      .then(data => console.log(data))
      .catch(error => console.error('Error:', error));
}

function timeAdjustDEC() {
    fetch('/timeAdjustDEC')
      .then(response => response.text())
      .then(data => console.log(data))
      .catch(error => console.error('Error:', error));
}

function bufferReset() {
    fetch('/bufferReset')
      .then(response => response.text())
      .then(data => console.log(data))
      .catch(error => console.error('Error:', error));
}

function neverPress() {
    fetch('/neverPress')
      .then(response => response.text())
      .then(data => console.log(data))
      .catch(error => console.error('Error:', error));
}

function lockEEPROM() {
    fetch('/lockEEPROM')
      .then(response => response.text())
      .then(data => console.log(data))
      .catch(error => console.error('Error:', error));
}

        function updateVariables() {
            fetch('/variables')
                .then(response => response.json())
                .then(data => {
                    document.getElementById('unixTime').innerText = data.unixTime;
                    document.getElementById('nistTime').innerText = data.nistTime;
                    document.getElementById('date').innerText = data.date;
                    document.getElementById('time').innerText = data.time;
                    document.getElementById('displayMicroSeconds').innerText = data.displayMicroSeconds;
                    document.getElementById('microChange').innerText = data.microChange;
                    document.getElementById('pulseDurationBetweenRisingEdges').innerText = data.pulseDurationBetweenRisingEdges;
                    document.getElementById('timeAdjust').innerText = data.timeAdjust;
                    document.getElementById('differenceTime').innerText = data.differenceTime;
                    document.getElementById('freeMemory').innerText = data.freeMemory;
                    document.getElementById('averagePDBRE').innerText = data.averagePDBRE;
                    document.getElementById('runtimeSeconds').innerText = data.runtimeSeconds;
                    document.getElementById('totalPDBRE').innerText = data.totalPDBRE;
                    document.getElementById('adjustmentsMade').innerText = data.adjustmentsMade;
                    document.getElementById('xtalFreq').innerText = data.xtalFreq;
                    document.getElementById('avgXtalFreq').innerText = data.avgXtalFreq;
                    document.getElementById('fault').innerText = data.fault;
                    document.getElementById('globalPulseCount').innerText = data.globalPulseCount;
                    document.getElementById('espRuntime').innerText = data.espRuntime;
                    document.getElementById('lastNistUpdate').innerText = data.lastNistUpdate;
                    document.getElementById('runtimeHours').innerText = data.runtimeHours;
                    document.getElementById('faultCode').innerText = data.faultCode;
                    document.getElementById('bootupTime').innerText = data.bootupTime;
                    document.getElementById('status').innerText = data.status;
                    document.getElementById('stackSize').innerText = data.stackSize;
                    document.getElementById('ntpRequests').innerText = data.ntpRequests;
                });
        }
        setInterval(updateVariables, 500);
    


  
    
          EACP
        (ElectroKev Atomic Clock Project)
    
    
    Developed by Kevin Davy | January 2025 
    
    
        Loading version...
        
            Last Code Upload: )rawliteral" + String(buildDate) + " at " + String(buildTime) +  R"rawliteral( 
        
    

    
        NTP Server:
        clock.electrokev.com Port 123 
        Status:
        -
    

    Bootup Time: 

    
        function updateVersion() {
            fetch('/getVersion')
                .then(response => response.json())
                .then(data => {
                    document.getElementById('version').innerText = "Code Version " + data.version;
                });
        }
        updateVersion();
    



        
            EACP UNIX: -
            EACP Time: -
            NIST UNIX: -
            Date: -
            NIST Update: -
            Accuracy: - seconds
            xTal Freq - MHz
            Avg xTal Freq - MHz
            xTal Faults: -
            PDBRE: - μS
            Avg PDBRE: - μs
            PDBRE Err: - μs
            Adjust: - s/Day
            μs/Pulse+-: -
            PulseAdj/Next:  - / - Hrs
            RunTime (Sec/Hrs) - / -
            ESP RunTime -
            Free Memory: - bytes
            Stack Size: - bytes
            Global PC: -
            NTP Requests: -
            -
             
        
    

   Serial Output Console
     
  

  
    function addSerialMessage(message) {
     
    document.getElementById('serialOutput').innerHTML = ''; // Clears the content
    const serialOutput = document.getElementById('serialOutput');
    const currentTime = new Date().toLocaleTimeString();
    const faultCode = document.getElementById('faultCode').innerText; // Get faultCode
    const newMessage = `<div> <span class="timestamp">UTC: [${currentTime}]</span><br>${message}<strong> ${faultCode} </strong></div>`; 
    serialOutput.innerHTML += newMessage;

    // Scroll to the bottom
    serialOutput.scrollTop = serialOutput.scrollHeight;
}

    
//trigger the function every 2 seconds to update the console
    setInterval(() => {
      addSerialMessage(" ");
    }, 2000); // Every 2 seconds, add a new message



      


  Administrator Controls - Auth Use Only
  
  
  
    
    Unlock Controls
  

  
    
      Sync with NTP
      microUP
      mSADJDAYINC
      EEPROM
      Fault Reset
      microDOWN
      mSADJDayDEC
      
        NEVER Press this Button
      
    
  



  /* 
  // Original password-protected function (commented out)
  const correctPassword = "kev"; 

  function unlockControls() {
    const enteredPassword = document.getElementById("admin-password").value;
    
    if (enteredPassword === correctPassword) {
      // Enable all buttons after password is correct
      const buttons = document.querySelectorAll("#button-section button");
      buttons.forEach(button => {
        button.disabled = false;
      });
      document.getElementById("unlock-section").style.display = "none";  // Hide the password input
    } else {
      alert("Password Incorrect");
    }
  }
  */

  // New function to unlock controls without a password
  function unlockControls() {
    // Enable all buttons without requiring a password
    const buttons = document.querySelectorAll("#button-section button");
    buttons.forEach(button => {
      button.disabled = false;
    });
    
  }

  // Automatically unlock controls on page load (optional)
  window.onload = unlockControls;


  
  // Confirmation action function
  function confirmAction(action, callback) {
    if (confirm("Are you sure you want to " + action + "?")) {
      callback();
    }
  }





  ElectroKev Atomic Clock Features
  
  [28/01/25] ** Source Code View ** v50.3
  [29/01/25] ** Source Code View ** v51.1
  [05/02/25] ** Source Code View ** v51.7
   * Latest Version *
  
  The ElectroKev Atomic Clock incorporates advanced pulse monitoring and modulation technology, with precise synchronization and real-time correction of the time signal, ensuring microsecond-level accuracy. By tracking pulse drift from an oven-controlled OCXO 10 MHz Crystal, the clock dynamically compensates for drift, maintaining highly accurate timekeeping over extended periods.
  Key Features:
  
    Advanced Pulse Monitoring: The 10 MHz crystal is scaled by factors of 10 using 6 layers of the 74LS90 integrated circuit, creating a final 10 Hz input. The system continuously monitors and adjusts pulse signals to ensure precise timing.
    Real-Time Drift Compensation: Compensates for long-term drift with microsecond-level accuracy, ensuring stable timekeeping.
    User-Controlled Adjustments: Allows fine-tuning of pulse skipping and drift compensation to achieve optimal accuracy.
    Multi-LCD Display System: Provides detailed data on time, pulse handling, drift calculations, and temperature.
    Temperature Monitoring: Offers real-time temperature data with high precision and alerts for sensor malfunctions.
    Output Signal Control: Generates a modulated output signal with precise timing adjustments for synchronization.
    Rotary Encoder Integration: Enables precise adjustments to pulse modulation intervals with high resolution.
    Modular Design: Easily customizable to integrate additional sensors and outputs, enhancing functionality.
    Twin stabilised power supplies: The system uses twin power supplies, both stabilised and voltage and current regulated for accurate crystal oscillation.


Further Details: 


  The clock is an advanced, pulse-modulated timekeeping system that achieves exceptional precision by synchronizing with an external oven controlled crystal outputting a 10 MHz pulse. This system is designed to manipulate pulses with a remarkable accuracy of up to 1 microsecond per 24 hours, ensuring precise timekeeping over extended periods. It uses a segregated NIST time reference for synchronization, allowing it to maintain accurate time based on trusted atomic standards.



  For manual adjustments, the clock offers controls to modify the time and date, allowing for fine-tuning through pulse injection or removal. A rotary encoder is used to adjust the daily time alteration rate, with the system automatically calculating the microseconds per pulse to ensure precision. This feature makes it adaptable for environments requiring high-precision time adjustments.



  The clock is equipped with WiFi connectivity, enabling remote monitoring and control through a web application. This interface provides full access to all major clock functions, allowing users to interact with the system easily via server handlers. Continuous crystal speed monitoring helps identify and debug drift, ensuring the clock remains highly accurate.



  In terms of fault detection, the clock is designed to identify invalid pulse readings automatically. It removes faulty pulses and logs debug data for analysis, making it resilient to errors. Additionally, the power supply is finely tuned, with regulated power sources dedicated to different components to ensure stable performance.



  Firmware updates are handled over the air (OTA), ensuring the clock remains up-to-date without requiring manual intervention. The clock operates with a dual-core configuration, separating timekeeping functions from resource-intensive processes to optimize performance. Pulse monitoring inputs are prioritized through high-level processor interrupts for precise counting.



  The clock also functions as a globally accessible NTP time server on port 123, providing highly accurate time synchronization. A web-based serial console is available for real-time debugging, with self-monitoring and error reporting capabilities to facilitate automatic correction of any issues.






  ElectroKev.com
  © Kevin Davy 2025

     Last Code Upload: )rawliteral"
                + String(buildDate) + " at " + String(buildTime) + R"rawliteral(




)rawliteral";

  request->send(200, "text/html", html);
}