Initial commit: Sapphire ESP32 Laser Controller with README and .gitignore

This commit is contained in:
Ben
2024-12-10 12:00:00 -08:00
commit 2ea3ecc04d
3 changed files with 999 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
build/
.vscode/
*.bin
*.elf
*.map
+83
View File
@@ -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 <repository-url>
```
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://<ip-address>/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.*
+911
View File
@@ -0,0 +1,911 @@
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ElegantOTA.h>
#include <ArduinoJson.h>
#include <math.h>
// 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(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Sapphire Controller</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: sans-serif; background: #f0f0f0; text-align: center; padding: 50px; }
.btn { text-decoration: none; background: #3b82f6; color: #fff; padding: 15px 25px; border-radius: 4px; display: inline-block; margin: 10px; }
.btn:hover { background: #2563eb; }
.pinout { margin-top: 30px; }
table { margin: 0 auto; border-collapse: collapse; background: #fff; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
table, th, td { border: 1px solid #ccc; padding: 12px; }
th { background: #e0e0e0; }
td:first-child { text-align: left; font-weight: bold; }
.version { margin-top: 0.5rem; color: #666; font-size: 0.9rem; }
</style>
</head>
<body>
<h1>Sapphire Laser Controller</h1>
<p class="version">Firmware v%VERSION%</p>
<div>
<a href="/update" class="btn">Firmware Update</a>
</div>
<div class="pinout">
<h3>Serial Communication Pinout</h3>
<table>
<tr><th>Port</th><th>RX Pin</th><th>TX Pin</th><th>Notes</th></tr>
<tr><td>Serial 1</td><td>GPIO 26</td><td>GPIO 27</td><td>Standard UART2</td></tr>
<tr><td>Serial 2</td><td>GPIO 32</td><td>GPIO 33</td><td>Safe GPIO (UART1)</td></tr>
<tr><td>Serial 3</td><td>GPIO 18</td><td>GPIO 19</td><td>Safe GPIO (SPI pins available if SPI unused)</td></tr>
</table>
</div>
</body>
</html>
)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);
}
}