Fluke PM6680B – OCXO with Web GUI Calibration

  A very quick note on my Fluke PM6680 Universal Counter. How to add a £5 OCXO to compete with some of the best 10mhz references out there.  It also includes an Arduino Wemos D1 Pro Mini to monitor the temperature and the code to display that data on a webpage along with a RSSI. T the time of writing there is no DS18B20 attached, hence -127c .       Firstly, the project was merely to add an OCXO to my PM6680B but believe the pinouts for the PM6681 and others are similar “however and a word of caution”, some Universal counters from Fluke utilise a 24v OCXO but this can be passed into a buck converter (has the potential to introduce switching noise) or a decent high current regulator to bring it down to 12vdc. The board is very, very basic and merely a piggy back board from a cheap OCXO. The clever part is really the code and the ability to plot and adjust the Vref. As the OCXO which comprises of a OSC5Ab02 v2 (important as the v1 was totally pathetic). The v2 has v scoring around the regulator for better heat control but the main difference is the stability. I don’t own the v1 but many have said it’s excellent aside from some early issues “regulator heating and apparently better run at 9vdc rather than full on 12vdc”.

 
  The OCXO module again utilises an LT1009 precision 2.5vdc reference which strangely has its own Vref bypassed according to one article. It’s setup on the 2.5vdc being held stable and relies on manual adjustment via a cermet 200k 10 turn trimmer (I guess you could just wire in a Bourns 10 turn potentiometer and have that poking out the back?).
 
  However, it wasn’t long before I realised that temperature played a significant role in the stability. The piggy back board only ever meant to serve as a temperature monitor with a web interface, but rather fortuitously it dawned on me to either use digital potentiometer in place of the cermet (200k is quite rare for a pot of this magnitude due to the raw steps needed and byte count to control it). 200k is a lot of resolution for 1ohm steps. Then it dawned on me that the actual range of the trim is only around 5k or so, perhaps less. So it would be easier to combine the voltage with the DAC and have better resolution. 4096 if my memory serves me right at 5vdc and even higher resolution at 3.3vdc at VCC of the DAC but less range. For our purposes we need only 1vdc (the specs of the OSC5AB02 quote the Vref is 2vdc +\- 2vdc that’s a max of 4vdc) so I will modify the code later and reposition the 5vdc supply to 3.3vdc and up the resolution but we’re really getting into silly territory as this still is just a £5 OCXO and it must be treated as such. It’s 10 times more stable than the original crystal and that is the goal. 
 
  So lastly, the DS18B20 begins to sniff the temperature and report that back to the Arduino Wemos D1 pro mini. The Wemos does nothing but report it to the web interface at this time. The Vref is set in the code to come on at 1vdc. I believe the OCXO original Vref passes through a few resistors to divide it down. Hence the frequency is low due to the clamping action. The really clever part is where the Vref can be controlled by adjusting the raw DAC output and thus the voltage. 390 raw is around 0.429vdc. If we take the temperature at say 40 centigrade and then calibrate the PM6680 or equivalent and keep mapping temperature to corrections then we built a quite reliable table to self adjust. The plot is stored and the more samples you log the better the software should understand and average out. Then OCXO is then disciplined from the temperate rather than the voltage (well voltage but it’s now in a loop that’s governed from the temperate against millivolt curve). Obviously two voltages stored with difference temperatures should average between the two…. Hopefully that shouldn’t happen though. 
 
  I will make some adjustment to the code for F/C for those using Fahrenheit and a switch. 
 
  Lastly, and finally. The calibration is heavily dependent on a decent 10mhz reference that’s better than the OCXO you’re trying to work on. I used a GPSDO that’s 10.000,000,000,009 (10^-11) off at worst case scenario. The OCXO and the Fluke will never be that good to align with its accuracy but you would always want something rock solid at least to plot the calibration curve. After that….. it should be good to never need adjusting again (unless the crystal in the oven ages faster than expected).
 
   The Piggy back PCB is really, really basic but will put the gerbers etc on my website. 
 
 If you’re still reading and haven’t dozed off, the frequency is presented “intentionally” to an SMA connector. A short 10cm SMA to SMA links the piggy back board to the OCXO. An IPEX cable links the Wemos D1 pro mini to an external antenna (important as the heavy shielding of the Fluke blocks WiFi.) a standard 2.1mm x 5.5mm dc jack connected the power to the piggy back board “just, it’s very tight”. Basically you can run this board into any equipment that needs a 10mhz source for reference (I forget the p-p voltage but it has an inline PAD, I think I used a 1206 4.7k chip resistor). I believe the original crystal voltage is around 9v….. well the guy at “the Signal Path” said it was!!
 
  You can easily increase or decrease the PAD resistance if needed. It’s not divided just basic resistive attenuation but works and this was never meant to be more than a prototype….. worked so well I just left it. 
 
 I’m sure I’ve made mistakes and welcome constructive criticism and I will make the changes to v2.0 if needed.
 

    Please use the calculators page to find the date to seconds converter and calculate the OCXO age to seconds. I decided not to add this feature to the Web GUI and rather enter the age through serial monitor. BEWARE though!! any future updates to the code with require you to note down the crystal ovens age and then enter that age in serial monitor: Example, the OCXO is 1 year old, you would take the number from the calculator and enter > setage 31, Finally the code between the Astrix

***************************************************************************************************************

#include <Wire.h>

#include <Adafruit_MCP4725.h>

#include <EEPROM.h>

#include <ESP8266WiFi.h>

#include <ESP8266WebServer.h>

#include <ESP8266mDNS.h>

#include <OneWire.h>

#include <DallasTemperature.h>

// ———————————————————————————————————————————————

// CONFIG from here onwards – please don’t mess with it unless you understand what its doing

// ———————————————————————————————————————————————

#define ONE_WIRE_BUS        D4

#define EEPROM_SIZE         2048 // Increased EEPROM size to accommodate more calibration points

#define SSID_ADDR           0      // 32 bytes

#define PASS_ADDR           32     // 64 bytes

#define TRIM_ADDR           96     // uint16_t

#define OCXO_ACCUM_UPTIME_ADDR 160 // uint64_t for accumulated uptime

#define AUTOTRIM_MODE_ADDR  168    // bool for auto trim mode

#define NBANKS              3

#define MAX_CAL_POINTS      10     // Reverted to 10 calibration points as requested

#define CAL_COUNT_ADDR(b)   (120 + (b)*((1 + MAX_CAL_POINTS*sizeof(CalPoint))))

#define CAL_ENTRY_ADDR(b)   (CAL_COUNT_ADDR(b) + 1)

#define DAC_RES             4096

#define DAC_VCC             5.0

#define MAX_DAC_VOLTAGE     1.0    // ±1 V swing

#define MAX_DAC_COUNT       ((uint16_t)((MAX_DAC_VOLTAGE/DAC_VCC)*(DAC_RES-1)))

#define TEMP_INTERVAL       5000   // ms (for sensor reads and EEPROM save)

#define EEPROM_SAVE_INTERVAL 60000 // ms (save accumulated uptime every minute)

const char* CONFIG_AP_SSID = “G5STO_Config”;

const char* CONFIG_AP_PWD  = “config1234”;

// ———————————————————————————————————————————————

// GLOBAL Values – Again leave well alone

// ———————————————————————————————————————————————

OneWire oneWire(ONE_WIRE_BUS);

DallasTemperature sensors(&oneWire);

Adafruit_MCP4725 dac;

ESP8266WebServer server(80); // Global declaration for server

char      storedSSID[32];

char      storedPASS[64];

uint16_t  trimVal         = MAX_DAC_COUNT/2; // Global declaration for trimVal

float     currentTemp     = NAN; // Global declaration for currentTemp

int       currentRSSI     = -127; // Global declaration for currentRSSI

unsigned long lastTempMs  = 0;

uint8_t   currentBank     = 0; // Global declaration for currentBank

uint64_t  ocxoAccumulatedUptime = 0; // Stored in EEPROM for OCXO age

unsigned long lastEepromSaveMs = 0; // To control EEPROM write frequency

bool      autoTrimEnabled = true; // New: Controls whether auto-trimming is active

// mDNS event

WiFiEventHandler onGotIP;

// Calibration point struct + table

// Added age_ms to CalPoint to store OCXO age at calibration time

struct CalPoint { uint16_t t10; uint16_t trim; uint64_t age_ms; };

CalPoint calPts[MAX_CAL_POINTS];

uint8_t  calCount = 0;

// ———————————————————————————————————————————————

// PROTOTYPE Code (Declares all functions before their definitions if they are called out of order)

// ———————————————————————————————————————————————

void loadCalibration(uint8_t bank);

void saveCalibrationPoint(uint8_t bank, uint16_t t10, uint16_t trim, uint64_t age_ms); // Updated prototype

void resetCalibration(uint8_t bank);

uint16_t lookupTrim(uint8_t bank, float tempC);

void onStationGotIP(const WiFiEventStationModeGotIP& evt);

void handleData();

void handleSet();

void handleCal();

void handleReset();

void handleBank();

void handleMap();

void handleRoot(); // Prototype for the main UI handler

void handleConfig();

void handleMode(); // New: Handles auto trim mode changes

// Removed handleResetOcxoAge() prototype as it’s now handled via Serial

// ———————————————————————————————————————————————

// Wi-Fi “got IP” callback

// ———————————————————————————————————————————————

void onStationGotIP(const WiFiEventStationModeGotIP& evt) {

  Serial.printf(“\n✅ STA IP: %s\n”, evt.ip.toString().c_str());

  if (MDNS.begin(“G5STO”)) {

    Serial.println(“🔗 http://G5STO.local”);

  }

}

// ———————————————————————————————————————————————

// /data → JSON { temp, ssid, rssi, uptime_seconds, ocxo_accum_uptime, auto_trim_enabled }

// ———————————————————————————————————————————————

void handleData() {

  String j = “{“;

  j += “\”temp\”:” + String(currentTemp,1) + “,”;

  j += “\”ssid\”:\”” + WiFi.SSID() + “\”,”;

  j += “\”rssi\”:” + String(currentRSSI);

  j += “,\”uptime_seconds\”:” + String(millis() / 1000); // ESP’s current uptime

  j += “,\”ocxo_accum_uptime\”:” + String((unsigned long)(ocxoAccumulatedUptime / 1000)); // Send in seconds

  j += String(“,\”auto_trim_enabled\”:”) + (autoTrimEnabled ? “true” : “false”); // New: Send auto trim mode

  j += “}”;

  server.sendHeader(“X-Content-Type-Options”, “nosniff”); // Added security header

  server.sendHeader(“Cache-Control”, “no-cache, no-store, must-revalidate”); // Added caching header

  server.send(200, “application/json; charset=utf-8”, j); // Added charset to Content-Type

}

// ———————————————————————————————————————————————

// /set?val=NNN → clamp & set DAC + EEPROM

// ———————————————————————————————————————————————

void handleSet() {

  Serial.println(“handleSet received request.”); // Diagnostic print

  if (server.hasArg(“val”)) {

    int v = server.arg(“val”).toInt();

    Serial.printf(“Received value from web: %d\n”, v); // Diagnostic print

    v = constrain(v, 0, MAX_DAC_COUNT);

    trimVal = v;

    Serial.printf(“DEBUG: Before DAC setVoltage (manual) – trimVal: %u\n”, trimVal); // ADDED DEBUG PRINT

    dac.setVoltage(trimVal, false); // Set DAC voltage

    Serial.printf(“DAC set to value: %u (%.3f V)\n”, trimVal, (float)trimVal * DAC_VCC / (DAC_RES – 1)); // Diagnostic print

    EEPROM.put(TRIM_ADDR, trimVal);

    if (EEPROM.commit()) {

      Serial.println(“EEPROM commit successful.”); // Diagnostic print

    } else {

      Serial.println(“EEPROM commit failed!”); // Diagnostic print

    }

  } else {

    Serial.println(“No ‘val’ argument found in /set request.”); // Diagnostic print

  }

  server.sendHeader(“X-Content-Type-Options”, “nosniff”); // Added security header

  server.sendHeader(“Cache-Control”, “no-cache, no-store, must-revalidate”); // Added caching header

  server.send(204); // Send No Content response

}

// ———————————————————————————————————————————————

// /cal → save current temp→trim to this bank (now also saves OCXO age)

// ———————————————————————————————————————————————

void handleCal() {

  uint16_t t10 = round(currentTemp * 10);

  saveCalibrationPoint(currentBank, t10, trimVal, ocxoAccumulatedUptime); // Pass accumulated uptime

  server.sendHeader(“X-Content-Type-Options”, “nosniff”); // Added security header

  server.sendHeader(“Cache-Control”, “no-cache, no-store, must-revalidate”); // Added caching header

  server.send(200, “text/plain”, “OK”);

}

// ———————————————————————————————————————————————

// /reset → clear this bank’s map

// ———————————————————————————————————————————————

void handleReset() {

  resetCalibration(currentBank);

  server.sendHeader(“X-Content-Type-Options”, “nosniff”); // Added security header

  server.sendHeader(“Cache-Control”, “no-cache, no-store, must-revalidate”); // Added caching header

  server.send(200, “text/plain”, “OK”);

}

// ———————————————————————————————————————————————

// /bank?b=N → switch bank

// ———————————————————————————————————————————————

void handleBank() {

  if (server.hasArg(“b”)) {

    int b = server.arg(“b”).toInt();

    if (b >= 0 && b < NBANKS) {

      currentBank = b;

      loadCalibration(currentBank);

    }

  }

  server.sendHeader(“X-Content-Type-Options”, “nosniff”); // Added security header

  server.sendHeader(“Cache-Control”, “no-cache, no-store, must-revalidate”); // Added caching header

  server.send(204);

}

// ———————————————————————————————————————————————

// /map → return JSON array of {t,trim,age_ms}

// ———————————————————————————————————————————————

void handleMap() {

  String j = “[“;

  for (uint8_t i = 0; i < calCount; i++) {

    j += “{\”t\”:” + String(calPts[i].t10/10.0,1) // Server sends already scaled temp

      + “,\”trim\”:” + String(calPts[i].trim)

      + “,\”age_ms\”:” + String((unsigned long)(calPts[i].age_ms / 1000)) + “}”; // Send age in seconds

    if (i < calCount – 1) j += “,”;

  }

  j += “]”;

  server.sendHeader(“X-Content-Type-Options”, “nosniff”); // Added security header

  server.sendHeader(“Cache-Control”, “no-cache, no-store, must-revalidate”); // Added caching header

  server.send(200, “application/json; charset=utf-8”, j); // Added charset to Content-Type

}

// ———————————————————————————————————————————————

// /mode?m=auto|learn → set auto trim mode

// ———————————————————————————————————————————————

void handleMode() {

  if (server.hasArg(“m”)) {

    String mode = server.arg(“m”);

    if (mode == “auto”) {

      autoTrimEnabled = true;

      Serial.println(“Auto Trim Mode: ON”);

    } else if (mode == “learn”) {

      autoTrimEnabled = false;

      Serial.println(“Auto Trim Mode: OFF (Learn Mode)”);

    }

    EEPROM.put(AUTOTRIM_MODE_ADDR, autoTrimEnabled);

    if (EEPROM.commit()) {

      Serial.println(“Auto Trim Mode committed to EEPROM.”);

    } else {

      Serial.println(“Failed to commit Auto Trim Mode to EEPROM!”);

    }

    Serial.printf(“DEBUG: handleMode – autoTrimEnabled set to %s\n”, autoTrimEnabled ? “true” : “false”);

  }

  server.sendHeader(“X-Content-Type-Options”, “nosniff”); // Added security header

  server.sendHeader(“Cache-Control”, “no-cache, no-store, must-revalidate”); // Added caching header

  server.send(204); // No Content

}

// ———————————————————————————————————————————————

// handleConfig(): Handles the Wi-Fi configuration page

// ———————————————————————————————————————————————

void handleConfig() {

  if (server.method() == HTTP_POST) {

    // Save new SSID and password

    if (server.hasArg(“ssid”) && server.hasArg(“password”)) {

      String newSsid = server.arg(“ssid”);

      String newPass = server.arg(“password”);

      newSsid.toCharArray(storedSSID, sizeof(storedSSID));

      newPass.toCharArray(storedPASS, sizeof(storedPASS));

      EEPROM.put(SSID_ADDR, storedSSID);

      EEPROM.put(PASS_ADDR, storedPASS);

      EEPROM.commit();

      String response = “<html><body><h1>Credentials Saved!</h1>”;

      response += “<p>Attempting to connect to: ” + newSsid + “</p>”;

      response += “<p>Restarting ESP in 5 seconds…</p>”;

      response += “<script>setTimeout(function(){ window.location.href = ‘/’; }, 5000);</script>”;

      response += “</body></html>”;

      server.sendHeader(“X-Content-Type-Options”, “nosniff”); // Added security header

      server.sendHeader(“Cache-Control”, “no-cache, no-store, must-revalidate”); // Added caching header

    server.send(200, “text/html”, response);

      delay(100);

      ESP.restart(); // Restart to connect with new credentials

    }

  } else {

    // Display config form

    String html = “<html><body>”;

    html += “<h1>Wi-Fi Configuration</h1>”;

    html += “<form method=’POST’ action=’/config’>”;

    html += “SSID: <input type=’text’ name=’ssid’ value='” + String(storedSSID) + “‘><br>”;

    html += “Password: <input type=’password’ name=’password’><br>”;

    html += “<input type=’submit’ value=’Save and Connect’>”;

    html += “</form>”;

    html += “</body></html>”;

    server.sendHeader(“X-Content-Type-Options”, “nosniff”); // Added security header

    server.sendHeader(“Cache-Control”, “no-cache, no-store, must-revalidate”); // Added caching header

    server.send(200, “text/html”, html);

  }

}

// ———————————————————————————————————————————————

// C++ helper function for temperature status (used in initial HTML render)

// ———————————————————————————————————————————————

String getTempStatusCpp(float celsius) {

  if (isnan(celsius)) { return “N/A”; }

  if (celsius < 25) { return “Warming up”; }

  else if (celsius >= 25 && celsius <= 50) { return “Last phase”; }

  else { return “Warmed up”; }

}

// ———————————————————————————————————————————————

// C++ helper function for formatting uptime (used in initial HTML render)

// ———————————————————————————————————————————————

String formatTimeCpp(uint64_t totalSeconds) {

  uint64_t years = totalSeconds / (365 * 24 * 3600);

  totalSeconds %= (365 * 24 * 3600);

  uint64_t days = totalSeconds / (24 * 3600);

  totalSeconds %= (24 * 3600);

  uint64_t hours = totalSeconds / 3600;

  totalSeconds %= 3600;

  uint64_t minutes = totalSeconds / 60;

  char buffer[100]; // Sufficient buffer size

  String result = “”;

  if (years > 0) {

    sprintf(buffer, “%llu years “, years);

    result += buffer;

  }

  if (days > 0) {

    sprintf(buffer, “%llu days “, days);

    result += buffer;

  }

  sprintf(buffer, “%02llu hours %02llu minutes”, hours, minutes); // Use %02llu for padding

  result += buffer;

  result.trim(); // Trim in place

  return result;

}

// ———————————————————————————————————————————————

// handleRoot(): Serves the main web page (dynamically generated)

// ———————————————————————————————————————————————

void handleRoot() {

  // Calculate initial values for display

  uint16_t currentTrimVal = trimVal; // Use a local copy for display

  float currentVolts = (float)currentTrimVal * DAC_VCC / (DAC_RES – 1);

  // Initial uptime for display

  unsigned long initialUptimeSeconds = millis() / 1000;

  int h = initialUptimeSeconds / 3600;

  int m = (initialUptimeSeconds % 3600) / 60;

  int s = initialUptimeSeconds % 60;

  // Debug print for the value used to generate HTML

  Serial.printf(“DEBUG: handleRoot – currentTrimVal for HTML: %u\n”, currentTrimVal);

  String html = String(“<!DOCTYPE html>”);

  html += String(“<html lang=’en’>”);

  html += String(“<head>”);

  html += String(“<meta charset=’utf-8′>”);

  html += String(“<meta name=’viewport’ content=’width=device-width, initial-scale=1.0′>”);

  html += String(“<title>G5STO – OCXO Monitor & Trim</title>”);

  html += String(“<script src=’https://cdn.jsdelivr.net/npm/chart.js’></script>”);  //Powerful JS scripting for charts – Not my work

  html += String(“<style>”);

  html += String(“body { font-family: ‘Inter’, sans-serif; text-align: center; margin: 0; padding: 1em; background-color: #f0f2f5; color: #333; box-sizing: border-box; }”);

  html += String(“h1, h2, h3, h4 { color: #2c3e50; margin-top: 0; }”);

  html += String(“main { display: flex; flex-wrap: wrap; justify-content: center; gap: 1.5em; max-width: 1200px; margin: 1em auto; }”);

  html += String(“.card { background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); padding: 1.5em; flex: 1 1 calc(33% – 3em); min-width: 280px; box-sizing: border-box; transition: transform 0.2s ease-in-out; }”);

  html += String(“.card:hover { transform: translateY(-5px); }”);

  html += String(“.display-value { font-size: 2.2em; font-weight: 700; color: #3498db; margin: 0.5em 0; }”);

  html += String(“.display-label { font-size: 0.9em; color: #7f8c8d; margin-bottom: 0.5em; }”);

  html += String(“meter { width: 100%; height: 25px; margin: 0.5em 0; border-radius: 5px; overflow: hidden; -webkit-appearance: none; appearance: none; }”);

  html += String(“meter::-webkit-meter-bar { background: #ecf0f1; border-radius: 5px; }”);

  html += String(“meter::-webkit-meter-optimum-value { background: #2ecc71; border-radius: 5px; }”);

  html += String(“meter::-webkit-meter-suboptimum-value { background: #f39c12; border-radius: 5px; }”);

  html += String(“meter::-webkit-meter-unsatisfied-value { background: #e74c3c; border-radius: 5px; }”);

  html += String(“button, select { background-color: #3498db; color: white; border: none; padding: 0.7em 1.2em; margin: 0.3em; border-radius: 8px; cursor: pointer; font-size: 0.9em; font-weight: 600; transition: background-color 0.3s ease, transform 0.1s ease; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }”);

  html += String(“button:hover, select:hover { background-color: #2980b9; transform: translateY(-1px); }”);

  html += String(“button:active { transform: translateY(0); box-shadow: none; }”);

  html += String(“.button-group { margin-top: 1em; }”);

  html += String(“.primary-btn { background-color: #27ae60; }”);

  html += String(“.primary-btn:hover { background-color: #229a56; }”);

  html += String(“.secondary-btn { background-color: #e74c3c; }”);

  html += String(“.secondary-btn:hover { background-color: #c0392b; }”);

  html += String(“input[type=’range’] { width: 90%; margin: 1em 0; -webkit-appearance: none; appearance: none; height: 8px; background: #bdc3c7; outline: none; border-radius: 5px; transition: opacity .2s; }”);

  html += String(“input[type=’range’]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 20px; height: 20px; border-radius: 50%; background: #3498db; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); }”);

  html += String(“input[type=’range’]::-moz-range-thumb { width: 20px; height: 20px; border-radius: 50%; background: #3498db; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); }”);

  html += String(“.slider-value-display { font-size: 1.1em; font-weight: bold; color: #3498db; margin-top: 0.5em; }”);

  html += String(“pre { background-color: #ecf0f1; border: 1px solid #dde1e6; border-radius: 8px; padding: 1em; max-height: 200px; overflow: auto; text-align: left; white-space: pre-wrap; word-wrap: break-word; margin-top: 1em; font-family: ‘Courier New’, monospace; font-size: 0.9em; }”);

  html += String(“#calChart, #ageDriftChart { width: 100%; max-height: 300px; margin-top: 1em; background-color: #ffffff; border-radius: 8px; padding: 1em; box-shadow: 0 2px 6px rgba(0,0,0,0.05); }”);

  html += String(“#feedbackMessage { margin-top: 1em; padding: 0.8em; border-radius: 8px; font-weight: bold; opacity: 0; transition: opacity 0.5s ease-in-out; }”);

  html += String(“#feedbackMessage.success { background-color: #d4edda; color: #155724; opacity: 1; }”);

  html += String(“#feedbackMessage.error { background-color: #f8d7da; color: #721c24; opacity: 1; }”);

  html += String(“#feedbackMessage.info { background-color: #cfe2ff; color: #052c65; opacity: 1; }”);

  html += String(“footer { margin-top: 2em; padding: 1em; color: #7f8c8d; font-size: 0.85em; }”);

  html += String(“.auto-trim-toggle-btn { background-color: #27ae60; width: auto; min-width: 120px; padding: 0.7em 1.2em; margin-top: 1em; }”);

  html += String(“.auto-trim-toggle-btn.off { background-color: #e74c3c; }”);

  html += String(“.auto-trim-toggle-btn:hover { transform: translateY(-1px); }”);

  html += String(“.auto-trim-toggle-btn.off:hover { background-color: #c0392b; }”);

  html += String(“@media (max-width: 992px) { .card { flex: 1 1 calc(50% – 3em); } }”);

  html += String(“@media (max-width: 600px) { .card { flex: 1 1 100%; } }”);

  html += String(“</style>”);

  html += String(“</head>”);

  html += String(“<body>”);

  html += String(“<header><h1>G5STO – OCXO Monitor & Trim</h1></header>”);

  html += String(“<main>”);

  html += String(“<section class=’card’>”);

  html += String(“<h2>System Status</h2>”);

  html += String(“<div class=’display-label’>Uptime:</div>”);

  html += String(“<p class=’display-value’ id=’uptime’>”) + String(h) + “:” + String(m/10) + String(m%10) + “:” + String(s/10) + String(s%10) + String(“</p>”);

  html += String(“<label for=’bank’ class=’display-label’>Calibration Bank:</label>”);

  html += String(“<select id=’bank’ onchange=’chgBank(this.value)’>”);

  for (int i = 0; i < NBANKS; i++) {

    html += String(“<option value='”) + String(i) + String(“‘”);

    if (i == currentBank) {

      html += String(” selected”);

    }

    html += String(“>Bank “) + String(i + 1) + String(“</option>”);

  }

  html += String(“</select>”);

  html += String(“</section>”);

  html += String(“<section class=’card’>”);

  html += String(“<h2>Temperature (DS18B20)</h2>”);

  html += String(“<p class=’display-value’><span id=’temp’>”) + String(currentTemp, 1) + String(“</span> <button id=’toggleTempUnit’ onclick=’toggleTempUnit()’>F</button></p>”); // Initial temp

  html += String(“<p class=’display-label’><span id=’tempStatus’>”) + getTempStatusCpp(currentTemp) + String(“</span></p>”); // Initial temp status

  html += String(“<meter id=’tempG’ min=’-40′ max=’125′ low=’0′ high=’50’ optimum=’25’ value='”) + String(currentTemp, 1) + String(“‘></meter>”); // Initial temp gauge

  html += String(“</section>”);

  html += String(“<section class=’card’>”);

  html += String(“<h2>Wi-Fi Signal</h2>”);

  html += String(“<p class=’display-label’>SSID: <span id=’ssid’>”) + WiFi.SSID() + String(“</span></p>”); // Initial SSID

  html += String(“<p class=’display-value’><span id=’rssiTxt’>”) + String(currentRSSI) + String(“</span> dBm</p>”); // Initial RSSI text

  html += String(“<meter id=’rssiG’ min=’-100′ max=’0′ low=’-80′ high=’-60′ optimum=’-40′ value='”) + String(currentRSSI) + String(“‘></meter>”); // Initial RSSI gauge

  html += String(“</section>”);

  html += String(“<section class=’card’>”);

  html += String(“<h2>OCXO Trim (&#177;”) + String(MAX_DAC_VOLTAGE, 1) + String(” V)</h2>”);

  html += String(“<input type=’range’ id=’coarse’ min=’0′ max='”) + String(MAX_DAC_COUNT) + String(“‘ value='”) + String(currentTrimVal) + String(“‘ oninput=’updateSliderDisplay(this.value)’ onchange=’setDAC(this.value)’ title=’OCXO Trim Value’>”);

  // Corrected: Initial voltage display without ‘ V’ inside the span. JS will add it.

  html += String(“<p class=’slider-value-display’><span id=’dacValueDisplay’>”) + String(currentVolts, 3) + String(“</span></p>”); // Removed ‘ V’ from here

  html += String(“<div class=’button-group’>”);

  html += String(“<button onclick=’nudge(-10)’>-10</button>”);

  html += String(“<button onclick=’nudge(-1)’>-1</button>”);

  html += String(“<button onclick=’nudge(1)’>+1</button>”);

  html += String(“<button onclick=’nudge(10)’>+10</button>”);

  html += String(“</div>”);

  html += String(“<div class=’button-group’>”);

  html += String(“<button class=’primary-btn’ onclick=’saveCal()’>Save Cal for Current Temp</button>”);

  html += String(“<button class=’secondary-btn’ onclick=’resetCal()’>Reset Cal Map</button>”);

  html += String(“</div>”);

  html += String(“<p id=’feedbackMessage’></p>”);

  html += String(“</section>”);

  html += String(“<section class=’card’>”);

  html += String(“<h2>OCXO Age</h2>”);

  html += String(“<p class=’display-value’ id=’ocxoAge’>”) + formatTimeCpp(ocxoAccumulatedUptime / 1000) + String(“</p>”); // Initial OCXO age

  html += String(“</section>”);

  html += String(“<section class=’card’>”);

  html += String(“<h2>Auto Trim Control</h2>”);

  html += String(“<button id=’autoTrimToggle’ class=’auto-trim-toggle-btn”) + (autoTrimEnabled ? “” : ” off”) + String(“‘ onclick=’toggleAutoTrimMode()’>Auto Trim: “) + (autoTrimEnabled ? “ON” : “OFF (Learn Mode)”) + String(“</button>”); // Initial auto trim state

  html += String(“<p class=’display-label’>When OFF, manual trim adjustments are not overwritten by temperature.</p>”);

  html += String(“</section>”);

  html += String(“<section class=’card’>”);

  html += String(“<h2>Calibration Data Table</h2>”);

  html += String(“<button onclick=’viewTable()’>View Table</button>”);

  html += String(“<pre id=’map’></pre>”);

  html += String(“</section>”);

  html += String(“<section class=’card’ style=’flex: 1 1 100%;’>”);

  html += String(“<h2>Calibration Plot (Temp vs. Voltage)</h2>”);

  html += String(“<button onclick=’viewPlot()’>View Plot</button>”);

  html += String(“<canvas id=’calChart’></canvas>”);

  html += String(“</section>”);

  html += String(“<section class=’card’ style=’flex: 1 1 100%;’>”);

  html += String(“<h2>Voltage Drift over OCXO Age</h2>”);

  html += String(“<button onclick=’viewAgeDriftPlot()’>View Age Drift Plot</button>”);

  html += String(“<canvas id=’ageDriftChart’></canvas>”);

  html += String(“</section>”);

  html += String(“</main>”);

  html += String(“<footer><p>&copy; 2025 G5STO OCXO Monitor. All rights reserved.</p></footer>”);

  html += String(“<script>”);

  html += String(“const MAX_DAC_COUNT = “) + String(MAX_DAC_COUNT) + String(“;”);

  html += String(“const DAC_RES = “) + String(DAC_RES) + String(“;”);

  html += String(“const DAC_VCC = “) + String(DAC_VCC, 3) + String(“;”);

  html += String(“const MAX_DAC_VOLTAGE = “) + String(MAX_DAC_VOLTAGE, 3) + String(“;”);

  html += String(“const TEMP_INTERVAL = “) + String(TEMP_INTERVAL) + String(“;”);

  html += String(“const NBANKS = “) + String((int)NBANKS) + String(“;”);

  html += String(“const CELSIUS_WARMING_UP_MAX = 25;”);

  html += String(“const CELSIUS_LAST_PHASE_MAX = 50;”);

  html += String(“let calChart;”);

  html += String(“let ageDriftChart;”);

  html += String(“let dacSetTimeout;”);

  html += String(“let isFahrenheit = localStorage.getItem(‘isFahrenheit’) === ‘true’;”);

  html += String(“let currentUptimeSeconds = “) + String(initialUptimeSeconds) + String(“;”);

  html += String(“let ocxoAccumulatedUptimeSeconds = “) + String((unsigned long)(ocxoAccumulatedUptime / 1000)) + String(“;”);

  html += String(“let isAutoTrimEnabled = “) + (autoTrimEnabled ? String(“true”) : String(“false”)) + String(“;”);

  html += String(“function showFeedback(message, type = ‘info’, duration = 3000) {“);

  html += String(”  const feedback = document.getElementById(‘feedbackMessage’);”);

  html += String(”  feedback.innerText = message;”);

  html += String(”  feedback.className = String(‘feedback-msg ‘) + type;”);

  html += String(”  clearTimeout(feedback.timer);”);

  html += String(”  feedback.timer = setTimeout(() => { feedback.className = String(‘feedback-msg’); feedback.innerText = ”; }, duration);”);

  html += String(“}”);

  html += String(“function updateSliderDisplay(value) {“);

  html += String(”  const volts = (value / (DAC_RES – 1)) * DAC_VCC;”);

  html += String(”  document.getElementById(‘dacValueDisplay’).innerText = volts.toFixed(3) + ‘ V’;”);

  html += String(“}”);

  html += String(“function convertCelsiusToFahrenheit(celsius) {“);

  html += String(”  return (celsius * 9 / 5) + 32;”);

  html += String(“}”);

  html += String(“function getTempStatus(celsius) {“);

  html += String(”  if (celsius < CELSIUS_WARMING_UP_MAX) { return ‘Warming up’; }”);

  html += String(”  else if (celsius >= CELSIUS_WARMING_UP_MAX && celsius <= CELSIUS_LAST_PHASE_MAX) { return ‘Last phase’; }”);

  html += String(”  else { return ‘Warmed up’; }”);

  html += String(“}”);

  html += String(“function formatTime(totalSeconds) {“);

  html += String(”  const years = Math.floor(totalSeconds / (365 * 24 * 3600));”);

  html += String(”  totalSeconds %= (365 * 24 * 3600);”);

  html += String(”  const days = Math.floor(totalSeconds / (24 * 3600));”);

  html += String(”  totalSeconds %= (24 * 3600);”);

  html += String(”  const hours = Math.floor(totalSeconds / 3600);”);

  html += String(”  totalSeconds %= 3600;”);

  html += String(”  const minutes = Math.floor(totalSeconds / 60);”);

  html += String(”  let result = ”;”);

  html += String(”  if (years > 0) result += String(years) + ‘ years ‘;”);

  html += String(”  if (days > 0) result += String(days) + ‘ days ‘;”);

  html += String(”  result += String(hours).padStart(2, ‘0’) + ‘ hours ‘ + String(minutes).padStart(2, ‘0’) + ‘ minutes’;”);

  html += String(”  return result.trim();”);

  html += String(“}”);

  html += String(“async function upd(){“);

  html += String(”  console.log(‘Fetching data from /data…’);”); // Added debug log

  html += String(”  try {“);

  html += String(”    let r = await fetch(‘/data’);”);

  html += String(”    if (!r.ok) {“);

  html += String(”      const errorText = await r.text();”); // Get response text for more info

  html += String(”      throw new Error(String(‘Network response for /data was not ok: ‘) + r.status + ‘ – ‘ + errorText);”);

  html += String(”    }”);

  html += String(”    const rawResponse = await r.text();”); // Get raw response text

  html += String(”    console.log(‘Raw /data response:’, rawResponse);”); // Log raw response

  html += String(”    let d = JSON.parse(rawResponse);”); // Parse JSON from raw text

  html += String(”    console.log(‘Parsed /data:’, d);”); // Log parsed JSON

  html += String(”    let displayTemp = d.temp;”);

  html += String(”    let tempUnit = ‘°C’;”);

  html += String(”    if (isFahrenheit) { displayTemp = convertCelsiusToFahrenheit(d.temp); tempUnit = ‘°F’; }”);

  html += String(”    document.getElementById(‘temp’).innerText = String(displayTemp.toFixed(1)) + ‘ ‘ + tempUnit;”);

  html += String(”    document.getElementById(‘tempG’).value = d.temp;”);

  html += String(”    document.getElementById(‘tempStatus’).innerText = getTempStatus(d.temp);”);

  html += String(”    document.getElementById(‘ssid’).innerText = d.ssid;”);

  html += String(”    document.getElementById(‘rssiG’).value = d.rssi;”);

  html += String(”    document.getElementById(‘rssiTxt’).innerText = String(d.rssi) + ‘ dBm’;”);

  html += String(”    currentUptimeSeconds = d.uptime_seconds;”);

  html += String(”    ocxoAccumulatedUptimeSeconds = d.ocxo_accum_uptime;”);

  html += String(”    isAutoTrimEnabled = d.auto_trim_enabled;”);

  html += String(”    updateAutoTrimToggleUI();”);

  html += String(”  } catch (error) {“);

  html += String(”    console.error(‘Error fetching or parsing data:’, error);”); // Updated error message

  html += String(”    showFeedback(String(‘Failed to update data: ‘) + error.message, ‘error’);”);

  html += String(”  }”);

  html += String(“}”);

  html += String(“let lastClientUptimeUpdate = Date.now();”);

  html += String(“function updateUptimeAndAgeDisplay() {“);

  html += String(”  const now = Date.now();”);

  html += String(”  const elapsed = (now – lastClientUptimeUpdate) / 1000;”);

  html += String(”  lastClientUptimeUpdate = now;”);

  html += String(”  currentUptimeSeconds += elapsed;”);

  html += String(”  const h = Math.floor(currentUptimeSeconds / 3600);”);

  html += String(”  const m = Math.floor((currentUptimeSeconds % 3600) / 60);”);

  html += String(”  const s = Math.floor(currentUptimeSeconds % 60);”);

  html += String(”  document.getElementById(‘uptime’).innerText = String(h) + ‘:’ + String(m).padStart(2, ‘0’) + ‘:’ + String(s).padStart(2, ‘0’);”);

  html += String(”  ocxoAccumulatedUptimeSeconds += elapsed;”);

  html += String(”  document.getElementById(‘ocxoAge’).innerText = formatTime(ocxoAccumulatedUptimeSeconds);”);

  html += String(“}”);

  html += String(“function toggleTempUnit() {“);

  html += String(”  isFahrenheit = !isFahrenheit;”);

  html += String(”  localStorage.setItem(‘isFahrenheit’, isFahrenheit);”);

  html += String(”  document.getElementById(‘toggleTempUnit’).innerText = isFahrenheit ? String(‘C’) : String(‘F’);”);

  html += String(”  upd();”);

  html += String(“}”);

  html += String(“function setDAC(v){“);

  html += String(”  console.log(‘setDAC called with value:’, v);”); // ADDED CLIENT-SIDE DEBUG

  html += String(”  clearTimeout(dacSetTimeout);”);

  html += String(”  dacSetTimeout = setTimeout(async () => {“);

  html += String(”    try {“);

  html += String(”      console.log(‘Attempting to fetch /set?val=’ + v);”); // ADDED CLIENT-SIDE DEBUG

  html += String(”      const response = await fetch(String(‘/set?val=’) + v);”);

  html += String(”      if (!response.ok) throw new Error(String(‘Network response for /set was not ok: ‘) + response.status);”);

  html += String(”      document.getElementById(‘coarse’).value = v;”);

  html += String(”      showFeedback(‘DAC value set successfully!’, ‘success’);”);

  html += String(”    } catch (error) {“);

  html += String(”      console.error(‘Error setting DAC:’, error);”);

  html += String(”      showFeedback(String(‘Failed to set DAC: ‘) + error.message, ‘error’);”);

  html += String(”    }”);

  html += String(”  }, 150);”);

  html += String(“}”);

  html += String(“function nudge(d){“);

  html += String(”  console.log(‘nudge called with delta:’, d);”); // ADDED CLIENT-SIDE DEBUG

  html += String(”  let c = document.getElementById(‘coarse’), nv = +c.value + d;”);

  html += String(”  nv = Math.max(0, Math.min(nv, MAX_DAC_COUNT));”);

  html += String(”  updateSliderDisplay(nv);”);

  html += String(”  setDAC(nv);”);

  html += String(“}”);

  html += String(“async function saveCal(){“);

  html += String(”  try {“);

  html += String(”    const response = await fetch(‘/cal’);”);

  html += String(”    if (!response.ok) throw new Error(String(‘Network response for /cal was not ok: ‘) + response.status);”);

  html += String(”    showFeedback(‘Calibration point saved!’, ‘success’);”);

  html += String(”    viewTable();”);

  html += String(”    viewPlot();”);

  html += String(”    viewAgeDriftPlot();”);

  html += String(”  } catch (error) {“);

  html += String(”    console.error(‘Error saving calibration:’, error);”);

  html += String(”    showFeedback(String(‘Failed to save calibration: ‘) + error.message, ‘error’);”);

  html += String(”  }”);

  html += String(“}”);

  html += String(“async function resetCal(){“);

  html += String(”  if (confirm(‘Are you sure you want to clear the calibration map for this bank?’)) {“);

  html += String(”    try {“);

  html += String(”      const response = await fetch(‘/reset’);”);

  html += String(”      if (!response.ok) throw new Error(String(‘Network response for /reset was not ok: ‘) + response.status);”);

  html += String(”      document.getElementById(‘map’).textContent = ‘No calibration points saved for this bank.’;”);

  html += String(”      calChart.data.labels = [];”);

  html += String(”      calChart.data.datasets[0].data = [];”);

  html += String(”      calChart.update();”);

  html += String(”      ageDriftChart.data.labels = [];”);

  html += String(”      ageDriftChart.data.datasets[0].data = [];”);

  html += String(”      ageDriftChart.update();”);

  html += String(”      showFeedback(‘Calibration map reset!’, ‘success’);”);

  html += String(”    } catch (error) {“);

  html += String(”      console.error(‘Error resetting calibration:’, error);”);

  html += String(”      showFeedback(String(‘Failed to reset calibration: ‘) + error.message, ‘error’);”);

  html += String(”    }”);

  html += String(”  }”);

  html += String(“}”);

  html += String(“async function viewTable(){“);

  html += String(”  try {“);

  html += String(”    let r = await fetch(‘/map’);”);

  html += String(”    if (!r.ok) throw new Error(String(‘Network response for /map was not ok: ‘) + r.status);”);

  html += String(”    let m = await r.json();”);

  html += String(”    let s = ”;”);

  html += String(”    if (m.length === 0) { s = ‘No calibration points saved for this bank.’; }”);

  html += String(”    else {“);

  html += String(”      s += String(‘Temp (°C) | Trim | Age (s)\\n’);”);

  html += String(”      s += String(‘————————-\\n’);”);

  html += String(”      m.forEach(p => s += String(p.t.toFixed(1)) + ‘ °C | ‘ + String(p.trim) + ‘ | ‘ + String(p.age_ms) + ‘\\n’);”);

  html += String(”    }”);

  html += String(”    document.getElementById(‘map’).textContent = s;”);

  html += String(”  } catch (error) {“);

  html += String(”    console.error(‘Error fetching map data:’, error);”);

  html += String(”    showFeedback(String(‘Failed to load calibration table: ‘) + error.message, ‘error’);”);

  html += String(”  }”);

  html += String(“}”);

  html += String(“async function viewPlot(){“);

  html += String(”  try {“);

  html += String(”    let r = await fetch(‘/map’);”);

  html += String(”    if (!r.ok) throw new Error(String(‘Network response for /map was not ok: ‘) + r.status);”);

  html += String(”    let m = await r.json();”);

  html += String(”    const temps = m.map(p => parseFloat(p.t));”);

  html += String(”    const volts = m.map(p => (p.trim / (DAC_RES – 1)) * DAC_VCC);”);

  html += String(”    calChart.data.labels = temps;”);

  html += String(”    calChart.data.datasets[0].data = volts;”);

  html += String(”    calChart.update();”);

  html += String(”  } catch (error) {“);

  html += String(”    console.error(‘Error fetching map data for Temp vs. Voltage plot:’, error);”);

  html += String(”    showFeedback(String(‘Failed to load calibration plot: ‘) + error.message, ‘error’);”);

  html += String(”  }”);

  html += String(“}”);

  html += String(“async function viewAgeDriftPlot(){“);

  html += String(”  try {“);

  html += String(”    let r = await fetch(‘/map’);”);

  html += String(”    if (!r.ok) throw new Error(String(‘Network response for /map was not ok: ‘) + r.status);”);

  html += String(”    let m = await r.json();”);

  html += String(”    m.sort((a, b) => a.age_ms – b.age_ms);”);

  html += String(”    const ages = m.map(p => formatTime(p.age_ms));”);

  html += String(”    const volts = m.map(p => (p.trim / (DAC_RES – 1)) * DAC_VCC);”);

  html += String(”    ageDriftChart.data.labels = ages;”);

  html += String(”    ageDriftChart.data.datasets[0].data = volts;”);

  html += String(”    ageDriftChart.update();”);

  html += String(”  } catch (error) {“);

  html += String(”    console.error(‘Error fetching map data for Age Drift plot:’, error);”);

  html += String(”    showFeedback(String(‘Failed to load age drift plot: ‘) + error.message, ‘error’);”);

  html += String(”  }”);

  html += String(“}”);

  html += String(“async function chgBank(b){“);

  html += String(”  try {“);

  html += String(”    const response = await fetch(String(‘/bank?b=’) + b);”);

  html += String(”    if (!response.ok) throw new Error(String(‘Network response for /bank was not ok: ‘) + response.status);”);

  html += String(”    showFeedback(String(‘Switched to Bank ‘) + (parseInt(b) + 1) + ‘!’, ‘success’);”);

  html += String(”    location.reload();”);

  html += String(”  } catch (error) {“);

  html += String(”    console.error(‘Error changing bank:’, error);”);

  html += String(”    showFeedback(String(‘Failed to switch bank: ‘) + error.message, ‘error’);”);

  html += String(”  }”);

  html += String(“}”);

  html += String(“async function toggleAutoTrimMode() {“);

  html += String(”  const newState = !isAutoTrimEnabled;”);

  html += String(”  const modeParam = newState ? String(‘auto’) : String(‘learn’);”);

  html += String(”  try {“);

  html += String(”    const response = await fetch(String(‘/mode?m=’) + modeParam);”);

  html += String(”    if (!response.ok) throw new Error(String(‘Network response for /mode was not ok: ‘) + response.status);”);

  html += String(”    isAutoTrimEnabled = newState;”);

  html += String(”    updateAutoTrimToggleUI();”);

  html += String(”    showFeedback(String(‘Auto Trim Mode: ‘) + (newState ? String(‘ON’) : String(‘OFF’)), ‘info’);”);

  html += String(”  } catch (error) {“);

  html += String(”    console.error(‘Error setting auto trim mode:’, error);”);

  html += String(”    showFeedback(String(‘Failed to change auto trim mode: ‘) + error.message, ‘error’);”);

  html += String(”  }”);

  html += String(“}”);

  html += String(“function updateAutoTrimToggleUI() {“);

  html += String(”  const toggleButton = document.getElementById(‘autoTrimToggle’);”);

  html += String(”  if (isAutoTrimEnabled) {“);

  html += String(”    toggleButton.innerText = ‘Auto Trim: ON’;”);

  html += String(”    toggleButton.classList.remove(‘off’);”);

  html += String(”  } else {“);

  html += String(”    toggleButton.innerText = ‘Auto Trim: OFF (Learn Mode)’;”);

  html += String(”    toggleButton.classList.add(‘off’);”);

  html += String(”  }”);

  html += String(“}”);

  html += String(“document.addEventListener(‘DOMContentLoaded’, () => {“);

  html += String(”  document.getElementById(‘toggleTempUnit’).innerText = isFahrenheit ? String(‘C’) : String(‘F’);”);

  html += String(”  const ctxCal = document.getElementById(‘calChart’).getContext(‘2d’);”);

  html += String(”  calChart = new Chart(ctxCal, {“);

  html += String(”    type:’line’,”);

  html += String(”    data:{ labels:[], datasets:[{“);

  html += String(”      label:String(‘Trim Voltage (V)’),”);

  html += String(”      borderColor:String(‘rgba(52, 152, 219, 1)’),”);

  html += String(”      backgroundColor:String(‘rgba(52, 152, 219, 0.2)’),”);

  html += String(”      fill:true, tension: 0.3, pointRadius: 5, pointBackgroundColor: String(‘rgba(52, 152, 219, 1)’), pointBorderColor: ‘#fff’, pointHoverRadius: 7, data:[ ]”);

  html += String(”    }] },”);

  html += String(”    options:{“);

  html += String(”      responsive: true, maintainAspectRatio: false,”);

  html += String(”      scales:{“);

  html += String(”        x:{ “);

  html += String(”          type: ‘linear’, “); // Explicitly set x-axis type to linear

  html += String(”          title:{ display:true, text:String(‘Temp (°C)’), font: { size: 14, weight: ‘bold’ } }, grid: { display: false }, ticks: { callback: function(value) { return String(value.toFixed(1)) + ‘ °C’; } } },”);

  html += String(”        y:{ title:{ display:true, text:String(‘Voltage (V)’), font: { size: 14, weight: ‘bold’ } }, min:0, max:MAX_DAC_VOLTAGE, ticks: { callback: function(value) { return String(value.toFixed(2)) + ‘ V’; } } }”);

  html += String(”      },”);

  html += String(”      plugins: { tooltip: { callbacks: { label: function(context) { return String(‘Voltage: ‘) + context.parsed.y.toFixed(3) + ‘ V’; }, title: function(context) { return String(‘Temp: ‘) + context[0].parsed.x.toFixed(1) + ‘ °C’; } } }, legend: { display: false } }”);

  html += String(”    }”);

  html += String(”  });”);

  html += String(”  const ctxAge = document.getElementById(‘ageDriftChart’).getContext(‘2d’);”);

  html += String(”  ageDriftChart = new Chart(ctxAge, {“);

  html += String(”    type:’line’,”);

  html += String(”    data:{ labels:[], datasets:[{“);

  html += String(”      label:String(‘Trim Voltage (V)’),”);

  html += String(”      borderColor:String(‘rgba(230, 126, 34, 1)’),”);

  html += String(”      backgroundColor:String(‘rgba(230, 126, 34, 0.2)’),”);

  html += String(”      fill:true, tension: 0.3, pointRadius: 5, pointBackgroundColor: String(‘rgba(230, 126, 34, 1)’), pointBorderColor: ‘#fff’, pointHoverRadius: 7, data:[ ]”);

  html += String(”    }] },”);

  html += String(”    options:{“);

  html += String(”      responsive: true, maintainAspectRatio: false,”);

  html += String(”      scales:{“);

  html += String(”        x:{ title:{ display:true, text:String(‘OCXO Age’), font: { size: 14, weight: ‘bold’ } }, grid: { display: false } },”);

  html += String(”        y:{ title:{ display:true, text:String(‘Voltage (V)’), font: { size: 14, weight: ‘bold’ } }, min:0, max:MAX_DAC_VOLTAGE, ticks: { callback: function(value) { return String(value.toFixed(2)) + ‘ V’; } } }”);

  html += String(”      },”);

  html += String(”      plugins: { tooltip: { callbacks: { label: function(context) { return String(‘Voltage: ‘) + context.parsed.y.toFixed(3) + ‘ V’; }, title: function(context) { return String(‘Age: ‘) + context.label; } } }, legend: { display: false } }”);

  html += String(”    }”);

  html += String(”  });”);

  html += String(”  upd();”);

  html += String(”  setInterval(upd, TEMP_INTERVAL);”);

  html += String(”  setInterval(updateUptimeAndAgeDisplay, 1000);”);

  html += String(”  updateSliderDisplay(document.getElementById(‘coarse’).value);”);

  html += String(”  viewTable();”);

  html += String(”  viewPlot();”);

  html += String(”  viewAgeDriftPlot();”);

  html += String(“});”);

  html += String(“</script>”);

  html += String(“</body>”);

  html += String(“</html>”);

  server.send(200, “text/html”, html);

}

// ———————————————————————————————————————————————

// loadCalibration(): Loads calibration points for a given bank from EEPROM

// ———————————————————————————————————————————————

void loadCalibration(uint8_t bank) {

  EEPROM.get(CAL_COUNT_ADDR(bank), calCount);

  if (calCount > MAX_CAL_POINTS) { // Sanity check

    calCount = 0;

  }

  for (uint8_t i = 0; i < calCount; i++) {

    EEPROM.get(CAL_ENTRY_ADDR(bank) + i * sizeof(CalPoint), calPts[i]);

  }

  Serial.printf(“Loaded %u calibration points from bank %u.\n”, calCount, bank);

  // Sort the calibration points by temperature (t10)

  for (uint8_t i = 0; i < calCount – 1; i++) {

    for (uint8_t j = i + 1; j < calCount; j++) {

      if (calPts[i].t10 > calPts[j].t10) {

        CalPoint temp = calPts[i];

        calPts[i] = calPts[j];

        calPts[j] = temp;

      }

    }

  }

}

// ———————————————————————————————————————————————

// saveCalibrationPoint(): Saves a new calibration point to EEPROM

// ———————————————————————————————————————————————

void saveCalibrationPoint(uint8_t bank, uint16_t t10, uint16_t trim, uint64_t age_ms) {

  if (calCount < MAX_CAL_POINTS) {

    calPts[calCount] = {t10, trim, age_ms};

    calCount++;

    EEPROM.put(CAL_COUNT_ADDR(bank), calCount);

    EEPROM.put(CAL_ENTRY_ADDR(bank) + (calCount – 1) * sizeof(CalPoint), calPts[calCount – 1]);

    if (EEPROM.commit()) {

      Serial.printf(“Saved calibration point: T=%u, Trim=%u, Age=%llu ms. Total: %u\n”, t10, trim, age_ms, calCount);

    } else {

      Serial.println(“EEPROM commit failed!”);

    }

    // Re-sort after adding a new point

    for (uint8_t i = 0; i < calCount – 1; i++) {

      for (uint8_t j = i + 1; j < calCount; j++) {

        if (calPts[i].t10 > calPts[j].t10) {

          CalPoint temp = calPts[i];

          calPts[i] = calPts[j];

          calPts[j] = temp;

        }

      }

    }

  } else {

    Serial.println(“Calibration map full. Cannot save new point.”); // 50 should be enough

  }

}

// ———————————————————————————————————————————————

// resetCalibration(): Clears all calibration points for a given bank

// ———————————————————————————————————————————————

void resetCalibration(uint8_t bank) {

  calCount = 0;

  EEPROM.put(CAL_COUNT_ADDR(bank), calCount);

  // Optionally clear the actual data area to zeros, though not strictly necessary for functionality

  // for (uint16_t i = 0; i < MAX_CAL_POINTS * sizeof(CalPoint); i++) {

  //   EEPROM.write(CAL_ENTRY_ADDR(bank) + i, 0);

  // }

  if (EEPROM.commit()) {

    Serial.printf(“Calibration map for bank %u reset.\n”, bank);

  } else {

    Serial.println(“EEPROM commit failed!”);

  }

}

// ———————————————————————————————————————————————

// lookupTrim(): Interpolates the trim value for a given temperature

// ———————————————————————————————————————————————

uint16_t lookupTrim(uint8_t bank, float tempC) {

  if (calCount == 0) {

    Serial.println(“No calibration points to interpolate.”);

    return trimVal; // Return current trim if no points

  }

  uint16_t t10 = round(tempC * 10);

  // If temperature is below the lowest calibration point

  if (t10 <= calPts[0].t10) {

    return calPts[0].trim;

  }

  // If temperature is above the highest calibration point

  if (t10 >= calPts[calCount – 1].t10) {

    return calPts[calCount – 1].trim;

  }

  // Find the two points for interpolation

  for (uint8_t i = 0; i < calCount – 1; i++) {

    if (t10 >= calPts[i].t10 && t10 <= calPts[i+1].t10) {

      uint16_t t_low = calPts[i].t10;

      uint16_t t_high = calPts[i+1].t10;

      uint16_t trim_low = calPts[i].trim;

      uint16_t trim_high = calPts[i+1].trim;

      // Linear interpolation

      // trim = trim_low + (trim_high – trim_low) * (t10 – t_low) / (t_high – t_low)

      // Corrected: Cast to float for intermediate calculation to prevent integer truncation

      uint16_t interpolatedTrim = trim_low + (uint16_t)(((float)(trim_high – trim_low) * (t10 – t_low)) / (t_high – t_low));

      Serial.printf(“Interpolating for temp %f (%u): between (%u, %u) and (%u, %u). Result: %u\n”,

                    tempC, t10, t_low, trim_low, t_high, trim_high, interpolatedTrim);

      return interpolatedTrim;

    }

  }

  // Should not reach here if points are sorted and boundary conditions handled

  Serial.println(“Lookup failed, returning current trim.”);

  return trimVal;

}

// ———————————————————————————————————————————————

// SETUP

// ———————————————————————————————————————————————

void setup() {

  Serial.begin(115200);

  Serial.println(“setup() function entered.”); // Diagnostic: Confirm setup() is called

  delay(100);

  Serial.println(“\nStarting OCXO Monitor…”);

  Wire.begin(); // Initialise I2C

  dac.begin(0x60); // <<<<<<<<<<<<<< IMPORTANT DAC ADDRESS CHANGED TO 0x60 – Sniff yours if the temperature fails >>>>>>>>>>>>>>

  sensors.begin(); // Initialise DS18B20 temperature sensor

  EEPROM.begin(EEPROM_SIZE); // Initialise EEPROM

  EEPROM.get(SSID_ADDR, storedSSID);

  EEPROM.get(PASS_ADDR, storedPASS);

  EEPROM.get(TRIM_ADDR, trimVal);

  EEPROM.get(OCXO_ACCUM_UPTIME_ADDR, ocxoAccumulatedUptime);

  EEPROM.get(AUTOTRIM_MODE_ADDR, autoTrimEnabled);

  // COMMENT OUT OR REMOVE THE FOLLOWING TWO LINES TO PREVENT RESETTING TRIM ON EVERY BOOT.

  // trimVal = MAX_DAC_COUNT / 2; // Force trimVal to a known good default

  // EEPROM.put(TRIM_ADDR, trimVal);

  // EEPROM.commit();

  // The code assumes you have kept OVEN On during reboot. If the OVEN IS OFF then this is required.

  // >>> END TEMPORARY RESET <<<

  // Load calibration points for the current bank

  loadCalibration(currentBank);

  Serial.printf(“Loaded SSID: ‘%s’\n”, storedSSID);

  Serial.printf(“Loaded Trim Value: %u (%.3f V)\n”, trimVal, (float)trimVal * DAC_VCC / (DAC_RES – 1));

  Serial.printf(“Loaded OCXO Accumulated Uptime: %llu ms\n”, ocxoAccumulatedUptime);

  Serial.printf(“Loaded Auto Trim Enabled: %s\n”, autoTrimEnabled ? “true” : “false”);

  // Set initial DAC voltage

  Serial.printf(“DEBUG: Before DAC setVoltage (initial setup) – trimVal: %u\n”, trimVal); // ADDED DEBUG PRINT

  dac.setVoltage(trimVal, false);

  // Connect to WiFi or start AP for configuration

  if (strlen(storedSSID) == 0) {

    Serial.println(“No WiFi credentials found. Starting AP mode.”);

    WiFi.mode(WIFI_AP);

    WiFi.softAP(CONFIG_AP_SSID, CONFIG_AP_PWD);

    Serial.printf(“AP IP address: %s\n”, WiFi.softAPIP().toString().c_str());

    server.on(“/”, handleConfig); // Serve config page on root in AP mode

    server.on(“/config”, handleConfig);

  } else {

    Serial.printf(“Connecting to WiFi: %s\n”, storedSSID);

    WiFi.mode(WIFI_STA);

    WiFi.begin(storedSSID, storedPASS);

    onGotIP = WiFi.onStationModeGotIP(onStationGotIP); // Register callback

    int attempts = 0;

    while (WiFi.status() != WL_CONNECTED && attempts < 30) { // Try for 15 seconds

      delay(500);

      Serial.print(“.”);

      attempts++;

    }

    if (WiFi.status() == WL_CONNECTED) {

      Serial.printf(“\nConnected to %s, IP: %s\n”, WiFi.SSID().c_str(), WiFi.localIP().toString().c_str());

      MDNS.begin(“G5STO”);

    } else {

      Serial.println(“\nFailed to connect to STA. Starting AP mode for configuration.”);

      WiFi.mode(WIFI_AP);

      WiFi.softAP(CONFIG_AP_SSID, CONFIG_AP_PWD);

      Serial.printf(“AP IP address: %s\n”, WiFi.softAPIP().toString().c_str());

      server.on(“/”, handleConfig); // Serve config page on root in AP mode

      server.on(“/config”, handleConfig);

    }

  }

  // Web server routes (common for both STA and AP modes, but root might differ)

  if (WiFi.getMode() == WIFI_STA) { // Only serve main UI if in STA mode

    server.on(“/”, handleRoot);

  }

  server.on(“/data”, handleData);

  server.on(“/set”, handleSet);

  server.on(“/cal”, handleCal);

  server.on(“/reset”, handleReset);

  server.on(“/bank”, handleBank);

  server.on(“/map”, handleMap);

  server.on(“/mode”, handleMode);

  server.begin();

  Serial.println(“HTTP server started”);

  lastEepromSaveMs = millis(); // Initialise last EEPROM save time

}

// ———————————————————————————————————————————————

// LOOP

// ———————————————————————————————————————————————

void loop() {

  server.handleClient();

  MDNS.update();

  // Temperature reading and auto-trim logic

  if (millis() – lastTempMs > TEMP_INTERVAL) {

    sensors.requestTemperatures();

    currentTemp = sensors.getTempCByIndex(0);

    currentRSSI = WiFi.RSSI(); // Update RSSI

    lastTempMs = millis();

    Serial.printf(“Temp: %.2f C, RSSI: %d dBm\n”, currentTemp, currentRSSI);

    if (autoTrimEnabled && !isnan(currentTemp)) {

      uint16_t newTrim = lookupTrim(currentBank, currentTemp);

      if (newTrim != trimVal) { // Only update if value changes

        trimVal = newTrim;

        Serial.printf(“DEBUG: Auto-trim – Before DAC setVoltage – trimVal: %u\n”, trimVal); // ADDED DEBUG PRINT

        dac.setVoltage(trimVal, false);

        EEPROM.put(TRIM_ADDR, trimVal); // Save immediately on auto-trim change

        EEPROM.commit();

        Serial.printf(“Auto-trim: DAC set to %u (%.3f V)\n”, trimVal, (float)trimVal * DAC_VCC / (DAC_RES – 1));

      }

    }

  }

  // Periodically save accumulated OCXO uptime to EEPROM

  if (millis() – lastEepromSaveMs > EEPROM_SAVE_INTERVAL) {

    ocxoAccumulatedUptime += (millis() – lastEepromSaveMs);

    EEPROM.put(OCXO_ACCUM_UPTIME_ADDR, ocxoAccumulatedUptime);

    if (EEPROM.commit()) {

      Serial.printf(“OCXO Accumulated Uptime saved: %llu ms\n”, ocxoAccumulatedUptime);   // Why? was to aid faster accumulation of temperature change

    } else {

      Serial.println(“EEPROM commit failed for uptime!”);

    }

    lastEepromSaveMs = millis();

  }

}

*****************************************************************************************************************