#include #include #include #include #include #include // WiFi Credentials const char* ssid = "SSID_PLACEHOLDER"; const char* password = "PASSWORD_PLACEHOLDER"; // Web Server & WebSocket AsyncWebServer server(80); AsyncWebSocket ws("/ws"); // Firmware Version //NOTE TO AI AGENTS: Increment this version upon any changes to the file const char* FIRMWARE_VERSION = "1.0.26"; // Serial Pin Definitions // Port 1: Uses UART2 (Serial2) - GPIO 26/27 const int SERIAL1_RX = 26; const int SERIAL1_TX = 27; // Port 2: Uses UART1 (Serial1) - GPIO 32/33 const int SERIAL2_RX = 32; const int SERIAL2_TX = 33; // Port 3: Uses remapped UART1 or software serial - GPIO 18/19 const int SERIAL3_RX = 18; const int SERIAL3_TX = 19; // Note: ESP32 has 3 UARTs. UART0 is debug. We remap UART1 and UART2 for laser ports. // Port 3 uses HardwareSerial with custom pins (can share UART with care or use SoftwareSerial). const unsigned long MIN_POLL_INTERVAL_MS = 1000; // Device recommendation: do not poll faster than 1 Hz per query unsigned long pollingRateMs = 1000; // HTML Index Page const char index_html[] PROGMEM = R"rawliteral( Sapphire Controller

Sapphire Laser Controller

Firmware v%VERSION%

Serial Communication Pinout

PortRX PinTX PinNotes
Serial 1GPIO 26GPIO 27Standard UART2
Serial 2GPIO 32GPIO 33Safe GPIO (UART1)
Serial 3GPIO 18GPIO 19Safe GPIO (SPI pins available if SPI unused)
)rawliteral"; String processor(const String& var) { if(var == "VERSION") return FIRMWARE_VERSION; return String(); } // --- Helper: Broadcast Response --- void broadcastSapphireResponse(int port, String response) { // 1. Print to USB Serial Serial.printf("[%d] < %s\n", port, response.c_str()); // 2. Send to WebSocket StaticJsonDocument<192> doc; doc["type"] = "serialRx"; doc["port"] = port; doc["data"] = response; String jsonString; serializeJson(doc, jsonString); ws.textAll(jsonString); } // --- Sapphire Driver --- class SapphireDriver { public: enum State { DISCONNECTED, CONNECTING, READY, FAULT }; enum CommandType { CMD_NONE, CMD_POWER, // ?P CMD_CURRENT, // ?C CMD_BASE_TEMP, // ?BT CMD_DIODE_TEMP, // ?DT CMD_CTRL_TEMP, // ?PST CMD_STATUS, // ?STA CMD_SET_TEMP, // ?DST CMD_HOURS, // ?HH CMD_CTRL_HOURS, // ?PSH CMD_WAVELENGTH, // ?WAVE CMD_MIN_POWER, // ?MINLP CMD_MAX_POWER, // ?MAXLP CMD_RESERVE, // ?DRC CMD_FAULT_FLAG, // ?FF CMD_SET_POWER, // P=... CMD_PROMPT, // > CMD_CONFIG_ECHO, CMD_CONFIG_PROMPT }; SapphireDriver(int id, HardwareSerial& serial, int rxPin, int txPin) : _id(id), _serial(serial), _rxPin(rxPin), _txPin(txPin) {} void begin() { beginSerial(); _state = DISCONNECTED; _lastActionMs = millis(); resetInitializationTracking(); Serial.printf("[Port %d] Initialized serial on RX=%d, TX=%d at 19200 baud\n", _id, _rxPin, _txPin); } void loop() { // Read incoming data while (_serial.available()) { char c = _serial.read(); _lastRxMs = millis(); // Handle prompt character '>' immediately if it appears alone if (c == '>') { _rxBuffer += c; processResponse(_rxBuffer); _rxBuffer = ""; continue; } // Early visibility into raw bytes during connection attempts if (_state != READY) { Serial.printf("[Port %d RAW] 0x%02X\n", _id, (uint8_t)c); } bool isTerminator = (c == '\n' || c == '\r'); if (isTerminator) { if (_rxBuffer.length() > 0) { processResponse(_rxBuffer); _rxBuffer = ""; } } else { _rxBuffer += c; } } // State Machine switch (_state) { case DISCONNECTED: if (millis() - _lastActionMs > 1000) { // Clear any stale data in buffer while (_serial.available()) _serial.read(); _rxBuffer = ""; // Try to connect Serial.printf("[Port %d] Attempting connection... sending ?FF\n", _id); _serial.println(); // Clear any partial command sendCommand("?FF", CMD_FAULT_FLAG); // Query Fault Flag to check presence _state = CONNECTING; _connectionAttempts++; } break; case CONNECTING: // Check for timeout if (millis() - _lastActionMs > CMD_TIMEOUT) { if (_rxBuffer.length() > 0) { processResponse(_rxBuffer); _rxBuffer = ""; } if (_waitingForResponse) { Serial.printf("[Port %d] Connection timeout (attempt %d), retrying...\n", _id, _connectionAttempts); _serial.print("\r\n"); _serial.flush(); _serial.end(); delay(10); beginSerial(); _state = DISCONNECTED; resetInitializationTracking(); _waitingForResponse = false; _lastActionMs = millis(); if (_lastRxMs == 0) { Serial.printf("[Port %d] No RX observed yet from device. Verify RS232 converter path.\n", _id); } } } else if (!_waitingForResponse && millis() - _lastActionMs >= MIN_CMD_INTERVAL) { sendCommand("?FF", CMD_FAULT_FLAG); _connectionAttempts++; } break; case READY: handleReadyState(); break; case FAULT: if (millis() - _lastActionMs > 5000) { Serial.printf("[Port %d] Fault recovery, attempting reconnection...\n", _id); _state = DISCONNECTED; resetInitializationTracking(); } break; } } void setPower(float mw) { if (_state == READY) { String cmd = "P=" + String(mw, 1); enqueueCommand(cmd, CMD_SET_POWER); } } void sendSerialCommand(String cmd) { // For manual commands, we don't know the type, so use NONE and rely on generic parsing if possible. // If we're not waiting for a response, send immediately to help with debugging even before READY. if (!_waitingForResponse && millis() - _lastResponseMs >= MIN_CMD_INTERVAL) { sendCommand(cmd, CMD_NONE); } else { enqueueCommand(cmd, CMD_NONE); } } // Getters for UI bool isConnected() { return _state == READY; } float getPower() { return _data.power; } float getBaseTemp() { return _data.baseTemp; } float getDiodeTemp() { return _data.diodeTemp; } float getCtrlTemp() { return _data.ctrlTemp; } float getCurrent() { return _data.current; } float getReserveCurrent() { return _data.reserveCurrent; } String getStatus() { return _data.status; } String getName() { return _data.name; } float getWavelength() { return _data.wavelength; } float getMinPower() { return _data.minPower; } float getMaxPower() { return _data.maxPower; } float getSetTemp() { return _data.setTemp; } int getHours() { return _data.hours; } int getCtrlHours() { return _data.ctrlHours; } private: int _id; HardwareSerial& _serial; int _rxPin, _txPin; State _state = DISCONNECTED; String _rxBuffer = ""; unsigned long _lastActionMs = 0; unsigned long _lastResponseMs = 0; int _connectionAttempts = 0; int _retryCount = 0; bool _isInitialized = false; const unsigned long MIN_CMD_INTERVAL = 500; const unsigned long CMD_TIMEOUT = 2000; struct Data { String name = "Sapphire"; float wavelength = 0; float power = 0; float minPower = 0; float maxPower = 0; float current = 0; float reserveCurrent = 0; float baseTemp = 0; float diodeTemp = 0; float ctrlTemp = 0; float setTemp = 0; int hours = 0; int ctrlHours = 0; String status = "Disconnected"; } _data; // Polling struct PollCmd { const char* cmd; CommandType type; }; static constexpr PollCmd _pollCommands[] = { {"?P", CMD_POWER}, {"?C", CMD_CURRENT}, {"?BT", CMD_BASE_TEMP}, {"?DT", CMD_DIODE_TEMP}, {"?PST", CMD_CTRL_TEMP}, {"?STA", CMD_STATUS}, {"?DST", CMD_SET_TEMP} }; static constexpr size_t POLL_COMMAND_COUNT = sizeof(_pollCommands) / sizeof(_pollCommands[0]); int _pollIndex = 0; unsigned long _lastPollMs = 0; unsigned long _lastStaticRetryMs = 0; unsigned long _initStartMs = 0; static constexpr unsigned long INIT_GRACE_MS = 5000; static constexpr unsigned long STATIC_RETRY_MS = 3000; // Initialization struct StaticQuery { const char* command; CommandType type; bool* satisfiedFlag; }; bool _hasHours = false; bool _hasCtrlHours = false; bool _hasWavelength = false; bool _hasSetTemp = false; bool _hasMinPower = false; bool _hasMaxPower = false; bool _hasReserve = false; bool _promptCommandSent = false; bool _echoConfigured = false; bool _promptConfigured = false; StaticQuery _initQueries[8] = { {">", CMD_PROMPT, nullptr}, {"?HH", CMD_HOURS, &_hasHours}, {"?PSH", CMD_CTRL_HOURS, &_hasCtrlHours}, {"?WAVE", CMD_WAVELENGTH, &_hasWavelength}, {"?DST", CMD_SET_TEMP, &_hasSetTemp}, {"?MINLP", CMD_MIN_POWER, &_hasMinPower}, {"?MAXLP", CMD_MAX_POWER, &_hasMaxPower}, {"?DRC", CMD_RESERVE, &_hasReserve} }; size_t _initIndex = 0; // Command Queue struct QueueItem { String cmd; CommandType type; }; QueueItem _pendingCommand = {"", CMD_NONE}; bool _waitingForResponse = false; String _lastSentCommandString = ""; CommandType _activeCommandType = CMD_NONE; unsigned long _lastRxMs = 0; void beginSerial() { _serial.begin(19200, SERIAL_8N1, _rxPin, _txPin); } void resetInitializationTracking() { _isInitialized = false; _hasHours = false; _hasCtrlHours = false; _hasWavelength = false; _hasSetTemp = false; _hasMinPower = false; _hasMaxPower = false; _hasReserve = false; _promptCommandSent = false; _echoConfigured = false; _promptConfigured = false; _initIndex = 0; _initStartMs = millis(); _lastStaticRetryMs = millis(); } void enqueueCommand(String cmd, CommandType type) { _pendingCommand = {cmd, type}; } bool allStaticFieldsSatisfied() const { return _hasHours && _hasCtrlHours && _hasWavelength && _hasSetTemp && _hasMinPower && _hasMaxPower && _hasReserve; } bool sendNextStaticQuery() { const size_t initCount = sizeof(_initQueries) / sizeof(_initQueries[0]); size_t attempts = 0; while (attempts < initCount) { StaticQuery &q = _initQueries[_initIndex]; bool satisfied = q.satisfiedFlag ? *(q.satisfiedFlag) : _promptCommandSent; _initIndex = (_initIndex + 1) % initCount; attempts++; if (satisfied) continue; if (!q.satisfiedFlag) { _promptCommandSent = true; } sendCommand(q.command, q.type); return true; } return false; } void handleReadyState() { if (_waitingForResponse) { if (millis() - _lastActionMs > CMD_TIMEOUT) { _serial.print("\r\n"); retryCommand(); } return; } if (millis() - _lastResponseMs < MIN_CMD_INTERVAL) { return; } if (_pendingCommand.cmd.length() > 0) { sendCommand(_pendingCommand.cmd, _pendingCommand.type); _pendingCommand = {"", CMD_NONE}; return; } // Apply device-side configuration first to keep parsing deterministic if (!_echoConfigured) { sendCommand("E=0", CMD_CONFIG_ECHO); return; } if (!_promptConfigured) { sendCommand(">0", CMD_CONFIG_PROMPT); return; } bool staticsComplete = allStaticFieldsSatisfied(); if (!_isInitialized) { if (sendNextStaticQuery()) return; if (staticsComplete) { Serial.printf("[Port %d] Static telemetry confirmed; enabling poll loop.\n", _id); _isInitialized = true; _lastPollMs = 0; _lastStaticRetryMs = millis(); } else if (millis() - _initStartMs > INIT_GRACE_MS) { Serial.printf("[Port %d] Static telemetry incomplete. Proceeding to poll.\n", _id); _isInitialized = true; _lastPollMs = 0; _lastStaticRetryMs = millis(); } else { return; } } else if (!staticsComplete && millis() - _lastStaticRetryMs >= STATIC_RETRY_MS) { if (sendNextStaticQuery()) { _lastStaticRetryMs = millis(); return; } } const unsigned long pollInterval = max(pollingRateMs, MIN_POLL_INTERVAL_MS); if (millis() - _lastPollMs >= pollInterval) { sendCommand(_pollCommands[_pollIndex].cmd, _pollCommands[_pollIndex].type); _pollIndex = (_pollIndex + 1) % POLL_COMMAND_COUNT; _lastPollMs = millis(); } } void sendCommand(String cmd, CommandType type) { Serial.printf("[Port %d TX] %s\n", _id, cmd.c_str()); _serial.println(cmd); _lastActionMs = millis(); _waitingForResponse = true; _lastSentCommandString = cmd; _activeCommandType = type; _retryCount = 0; } void retryCommand() { if (_retryCount < 3) { _retryCount++; Serial.printf("[Port %d] Retry %d for command %s\n", _id, _retryCount, _lastSentCommandString.c_str()); _serial.print("\r\n"); _serial.println(_lastSentCommandString); _lastActionMs = millis(); _waitingForResponse = true; } else { Serial.printf("[Port %d] Command %s failed after 3 retries\n", _id, _lastSentCommandString.c_str()); _waitingForResponse = false; _retryCount = 0; _activeCommandType = CMD_NONE; } } float parseFloatValue(const String& resp, float fallback = 0) { int start = -1; for (int i = 0; i < resp.length(); i++) { char c = resp[i]; if (isDigit(c) || c == '-' || c == '+' || c == '.') { start = i; break; } } if (start >= 0) { float parsed = resp.substring(start).toFloat(); if (!isnan(parsed)) return parsed; } return fallback; } int parseIntValue(const String& resp, int fallback = 0) { int start = -1; for (int i = 0; i < resp.length(); i++) { char c = resp[i]; if (isDigit(c) || c == '-' || c == '+') { start = i; break; } } if (start >= 0) { return resp.substring(start).toInt(); } return fallback; } String stripPromptPrefix(String resp) { String out = resp; out.trim(); if (out.startsWith("Sapphire")) { int arrowIndex = out.indexOf("->"); if (arrowIndex >= 0) { out = out.substring(arrowIndex + 2); } } out.trim(); return out; } bool isEchoLine(const String& resp) { String normalized = stripPromptPrefix(resp); if (normalized.length() == 0) return true; if (normalized == _lastSentCommandString) return true; return false; } void processResponse(String resp) { resp.trim(); if (resp.length() == 0) return; broadcastSapphireResponse(_id, resp); // 1. Check for Echo or prompt-only traffic if (isEchoLine(resp)) { return; } // 2. Normalize payload (remove prompt/echo prefix if present) String payload = stripPromptPrefix(resp); if (payload.startsWith(_lastSentCommandString)) { payload = payload.substring(_lastSentCommandString.length()); payload.trim(); } // If payload is empty after stripping echo, it was just an echo line. if (payload.length() == 0) return; // 3. Parse Data based on Active Command Context bool success = false; // Special handling for Fault Flag during connection if (_activeCommandType == CMD_FAULT_FLAG && _state == CONNECTING) { long flagValue = parseIntValue(payload, -1); bool warming = false; bool hasFaults = false; if (flagValue >= 0) { warming = (flagValue & (1 << 8)); // Bit 13 alone (8192) means ready/ok per protocol hasFaults = (flagValue != 0 && flagValue != 8192); } else { hasFaults = payload.indexOf("FAULT") >= 0 && payload.indexOf("NO FAULT") < 0; } if (warming) { _data.status = "Warming (bit 8 set)"; Serial.printf("[Port %d] Controller warming; waiting for ready flag.\n", _id); } else if (hasFaults) { _state = FAULT; _data.status = "Fault: " + payload; } else { _state = READY; Serial.printf("[Port %d] Connected. Response: %s\n", _id, payload.c_str()); } _waitingForResponse = false; _activeCommandType = CMD_NONE; _lastResponseMs = millis(); return; } switch (_activeCommandType) { case CMD_POWER: _data.power = parseFloatValue(payload, _data.power); success = true; break; case CMD_CURRENT: _data.current = parseFloatValue(payload, _data.current); success = true; break; case CMD_BASE_TEMP: _data.baseTemp = parseFloatValue(payload, _data.baseTemp); success = true; break; case CMD_DIODE_TEMP: _data.diodeTemp = parseFloatValue(payload, _data.diodeTemp); success = true; break; case CMD_CTRL_TEMP: _data.ctrlTemp = parseFloatValue(payload, _data.ctrlTemp); success = true; break; case CMD_HOURS: _data.hours = parseIntValue(payload, _data.hours); _hasHours = true; success = true; break; case CMD_CTRL_HOURS: _data.ctrlHours = parseIntValue(payload, _data.ctrlHours); _hasCtrlHours = true; success = true; break; case CMD_WAVELENGTH: _data.wavelength = parseFloatValue(payload, _data.wavelength); if (_data.wavelength > 0) { _data.name = "Sapphire " + String((int)_data.wavelength); _hasWavelength = true; } success = true; break; case CMD_MIN_POWER: _data.minPower = parseFloatValue(payload, _data.minPower); _hasMinPower = true; success = true; break; case CMD_MAX_POWER: _data.maxPower = parseFloatValue(payload, _data.maxPower); _hasMaxPower = true; success = true; break; case CMD_RESERVE: _data.reserveCurrent = parseFloatValue(payload, _data.reserveCurrent); _hasReserve = true; success = true; break; case CMD_SET_TEMP: _data.setTemp = parseFloatValue(payload, _data.setTemp); _hasSetTemp = true; success = true; break; case CMD_STATUS: { int status = parseIntValue(payload, -1); if (status >= 0) { switch(status) { case 1: _data.status = "Start up"; break; case 2: _data.status = "Warmup"; break; case 3: _data.status = "Standby"; break; case 4: _data.status = "Laser On"; break; case 5: _data.status = "Laser Ready"; break; case 6: _data.status = "Error"; break; default: _data.status = "Unknown"; break; } } else { _data.status = payload; } success = true; } break; case CMD_SET_POWER: // Response to P=... might be OK or new power or nothing if (payload.indexOf("OK") >= 0 || payload.length() > 0) { success = true; } break; case CMD_PROMPT: if (payload.indexOf(">") >= 0) success = true; break; case CMD_CONFIG_ECHO: _echoConfigured = true; success = true; Serial.printf("[Port %d] Echo disabled for clean responses.\n", _id); break; case CMD_CONFIG_PROMPT: _promptConfigured = true; _promptCommandSent = true; success = true; Serial.printf("[Port %d] Prompt disabled for clean responses.\n", _id); break; default: // CMD_NONE or unknown // Try to guess or just log Serial.printf("[Port %d] Unsolicited or unknown data: %s\n", _id, payload.c_str()); success = true; // Treat as handled so we don't get stuck? break; } if (success) { _waitingForResponse = false; _activeCommandType = CMD_NONE; _lastResponseMs = millis(); } } }; // Drivers SapphireDriver driver1(1, Serial2, SERIAL1_RX, SERIAL1_TX); SapphireDriver driver2(2, Serial1, SERIAL2_RX, SERIAL2_TX); // State unsigned long lastUpdateMs = 0; // pollingRateMs moved to top // JSON Buffer StaticJsonDocument<2048> doc; void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) { if (type == WS_EVT_CONNECT) { Serial.printf("WS Client connected: %u\n", client->id()); // Send immediate update on connect lastUpdateMs = 0; } else if (type == WS_EVT_DISCONNECT) { Serial.printf("WS Client disconnected: %u\n", client->id()); } else if (type == WS_EVT_DATA) { AwsFrameInfo *info = (AwsFrameInfo*)arg; if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) { data[len] = 0; String msg = (char*)data; JsonDocument inputDoc; DeserializationError error = deserializeJson(inputDoc, msg); if (!error) { if (inputDoc.containsKey("pollingRate")) { pollingRateMs = max((unsigned long)inputDoc["pollingRate"], MIN_POLL_INTERVAL_MS); Serial.printf("Polling rate set to: %lu ms (clamped to device-safe minimum of %lu ms)\n", pollingRateMs, MIN_POLL_INTERVAL_MS); } if (inputDoc["command"] == "setPower") { int port = inputDoc["port"]; float value = inputDoc["value"]; Serial.printf("Set Power Port %d: %.1f mW\n", port, value); if (port == 1) driver1.setPower(value); else if (port == 2) driver2.setPower(value); } else if (inputDoc["command"] == "raw") { String cmd = inputDoc["data"]; int port = inputDoc["port"] | 1; // Default to port 1 Serial.printf("[%d] > %s\n", port, cmd.c_str()); if (port == 1) driver1.sendSerialCommand(cmd); else if (port == 2) driver2.sendSerialCommand(cmd); } } } } } void setup() { Serial.begin(115200); // Init Drivers driver1.begin(); driver2.begin(); // Connect to WiFi WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); Serial.println(""); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(""); Serial.print("Connected to "); Serial.println(ssid); Serial.print("IP address: "); Serial.println(WiFi.localIP()); // Setup WebSocket ws.onEvent(onWsEvent); server.addHandler(&ws); // Setup ElegantOTA ElegantOTA.begin(&server); // Serve Index server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){ request->send_P(200, "text/html", index_html, processor); }); // Start Server server.begin(); Serial.println("HTTP server started"); } void loop() { ElegantOTA.loop(); ws.cleanupClients(); // Run Drivers driver1.loop(); driver2.loop(); // USB Serial Input if (Serial.available()) { String input = Serial.readStringUntil('\n'); input.trim(); if (input.length() > 0) { Serial.printf("[1] > %s\n", input.c_str()); driver1.sendSerialCommand(input); } } // Periodic Updates if (millis() - lastUpdateMs >= pollingRateMs) { lastUpdateMs = millis(); // Build JSON from real driver data doc.clear(); // Port 1 JsonObject p1 = doc.createNestedObject("1"); if (driver1.isConnected()) { p1["name"] = driver1.getName(); p1["wavelength"] = driver1.getWavelength(); p1["power"] = driver1.getPower(); p1["minPower"] = driver1.getMinPower(); p1["maxPower"] = driver1.getMaxPower(); p1["current"] = driver1.getCurrent(); p1["reserveCurrent"] = driver1.getReserveCurrent(); p1["baseTemp"] = driver1.getBaseTemp(); p1["diodeTemp"] = driver1.getDiodeTemp(); p1["ctrlTemp"] = driver1.getCtrlTemp(); p1["setTemp"] = driver1.getSetTemp(); p1["hours"] = driver1.getHours(); p1["ctrlHours"] = driver1.getCtrlHours(); p1["status"] = driver1.getStatus(); } else { p1["status"] = "Disconnected"; p1["name"] = "Port 1"; } // Port 2 JsonObject p2 = doc.createNestedObject("2"); if (driver2.isConnected()) { p2["name"] = driver2.getName(); p2["wavelength"] = driver2.getWavelength(); p2["power"] = driver2.getPower(); p2["minPower"] = driver2.getMinPower(); p2["maxPower"] = driver2.getMaxPower(); p2["current"] = driver2.getCurrent(); p2["reserveCurrent"] = driver2.getReserveCurrent(); p2["baseTemp"] = driver2.getBaseTemp(); p2["diodeTemp"] = driver2.getDiodeTemp(); p2["ctrlTemp"] = driver2.getCtrlTemp(); p2["setTemp"] = driver2.getSetTemp(); p2["hours"] = driver2.getHours(); p2["ctrlHours"] = driver2.getCtrlHours(); p2["status"] = driver2.getStatus(); } else { p2["status"] = "Disconnected"; p2["name"] = "Port 2"; } String jsonString; serializeJson(doc, jsonString); ws.textAll(jsonString); } }