From 2ea3ecc04dc1f4958fd98cfdb43117975b45e93d Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 10 Dec 2024 12:00:00 -0800 Subject: [PATCH] Initial commit: Sapphire ESP32 Laser Controller with README and .gitignore --- .gitignore | 5 + README.md | 83 +++++ Sapphire_ESP32.ino | 911 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 999 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 Sapphire_ESP32.ino diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d063668 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build/ +.vscode/ +*.bin +*.elf +*.map diff --git a/README.md b/README.md new file mode 100644 index 0000000..fef5e7e --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Sapphire ESP32 Laser Controller + +A robust, web-based controller for Coherent Sapphire lasers built on the ESP32 platform. This project provides a real-time telemetry dashboard and control interface for multiple Sapphire lasers via RS232 serial communication. + +## Features + +- **Multi-Port Support**: Control up to 3 Sapphire lasers simultaneously (2 implemented, 1 planned). +- **Real-Time Telemetry**: Monitor power, current, temperatures (base, diode, controller), and operational status via WebSockets. +- **Interactive Control**: Set laser power levels and send raw serial commands directly from the web interface or USB serial. +- **Automatic Discovery**: Smart state machine handles connection, initialization, and fault recovery for each laser port. +- **OTA Updates**: Seamless firmware updates over-the-air using [ElegantOTA](https://github.com/ayushsharma82/ElegantOTA). +- **Responsive Web Dashboard**: Built-in HTML/CSS interface served directly from the ESP32. + +## Hardware Requirements + +- **ESP32 Development Board** +- **RS232 to TTL Converters**: One for each laser port (Sapphire lasers use RS232 levels). +- **Coherent Sapphire Laser(s)** + +## Serial Pinout + +The ESP32 communicates with the lasers at **19200 baud (8N1)**. + +| Port | RX Pin | TX Pin | UART Peripheral | Notes | +| :--- | :--- | :--- | :--- | :--- | +| **Serial 1** | GPIO 26 | GPIO 27 | UART2 | Primary laser port | +| **Serial 2** | GPIO 32 | GPIO 33 | UART1 | Secondary laser port | +| **Serial 3** | GPIO 18 | GPIO 19 | UART1 (Remapped) | Optional/Planned | + +*Note: GPIO 1 is used for Debug/USB Serial (115200 baud).* + +## Software Dependencies + +Ensure you have the following libraries installed in your Arduino IDE or PlatformIO environment: + +- `ESPAsyncWebServer` +- `AsyncTCP` +- `ElegantOTA` +- `ArduinoJson` +- `WiFi` (Standard ESP32 library) + +## Installation & Setup + +1. **Clone the Repository**: + ```bash + git clone + ``` +2. **Configure WiFi**: + Open `Sapphire_ESP32.ino` and update the WiFi credentials: + ```cpp + const char* ssid = "Your_SSID"; + const char* password = "Your_Password"; + ``` +3. **Flash the ESP32**: + Upload the sketch using the Arduino IDE or your preferred tool. +4. **Connect Hardware**: + Connect the RS232 converters to the designated GPIO pins and the Sapphire lasers. + +## Usage + +### Web Interface +Once connected to WiFi, the ESP32 will print its IP address to the Serial Monitor. Navigate to this IP in your web browser to access the **Sapphire Controller Dashboard**. + +- **Main Dashboard**: View telemetry and control power. +- **Firmware Update**: Access `http:///update` to upload new firmware binaries. + +### Serial Console +You can also interact with Port 1 directly via the USB Serial Monitor (115200 baud). Any text entered will be sent as a command to the laser on Port 1. + +## Protocol Support + +The driver implements the Sapphire serial protocol, supporting commands such as: +- `?P`: Read output power +- `?C`: Read laser current +- `?STA`: Read system status +- `P=XXX.X`: Set laser power in mW +- `?FF`: Read fault flags + +## Firmware Versioning +The current firmware version is defined in the source code. Please increment `FIRMWARE_VERSION` in `Sapphire_ESP32.ino` when making changes. + +--- +*Developed for high-precision laser control applications.* diff --git a/Sapphire_ESP32.ino b/Sapphire_ESP32.ino new file mode 100644 index 0000000..c152e7b --- /dev/null +++ b/Sapphire_ESP32.ino @@ -0,0 +1,911 @@ +#include +#include +#include +#include +#include +#include + +// WiFi Credentials +const char* ssid = "Jelly"; +const char* password = "jellydoodle"; + +// 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); + } +}