Initial commit: Sapphire ESP32 Laser Controller with README and .gitignore
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
build/
|
||||
.vscode/
|
||||
*.bin
|
||||
*.elf
|
||||
*.map
|
||||
@@ -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.*
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user