/** * SerialCLI - A class for communicating with devices via Web Serial API * Handles sending commands, receiving responses, and parsing multi-line data */ export class SerialCLI { constructor(debug = false) { // Added debug parameter this.port = null; this.reader = null; this.writer = null; this.readBuffer = ""; this.isReading = false; this.commandQueue = []; this.currentCommand = null; this.decoder = new TextDecoder(); this.encoder = new TextEncoder(); this.responseTimeout = 5000; // 5 seconds timeout for responses this.commandDelay = 100; // 100ms delay between commands this.debug = debug; // Initialize debug mode } /** * Enable or disable debug logging * @param {boolean} enabled - True to enable debug mode, false to disable */ setDebug(enabled) { this.debug = enabled; if (this.debug) { console.log("SerialCLI Debug Mode Enabled"); } else { console.log("SerialCLI Debug Mode Disabled"); } } /** * Connect to a serial device * @param {number} baudRate - Baud rate to use (default: 115200) * @returns {Promise} True if connected, false otherwise */ async connect(baudRate = 115200) { if (!('serial' in navigator)) { console.error('Web Serial API not supported in this browser'); return false; } try { this.port = await navigator.serial.requestPort(); await this.port.open({ baudRate }); this.reader = this.port.readable.getReader(); this.writer = this.port.writable.getWriter(); if (this.debug) { console.log(`SerialCLI: Connected to port, baud rate ${baudRate}`); } this.startReading(); return true; // Indicate successful connection } catch (error) { console.error("SerialCLI: Failed to connect", error); this.port = null; // Reset port on failure return false; // Indicate failed connection } } /** * Disconnect from the serial device */ async disconnect() { if (this.reader) { try { this.isReading = false; await this.reader.cancel(); // releaseLock() is handled implicitly by cancel() or closing the port } catch (error) { if (this.debug) console.error("SerialCLI: Error cancelling reader", error); } finally { this.reader = null; } } if (this.writer) { try { // Ensure writer is closed before releasing lock if (!this.writer.closed) { await this.writer.close(); } } catch (error) { if (this.debug) console.error("SerialCLI: Error closing writer", error); } finally { try { this.writer.releaseLock(); } catch(lockError) { // Ignore error if lock was already released } this.writer = null; } } if (this.port) { try { await this.port.close(); if (this.debug) console.log("SerialCLI: Port closed"); } catch (error) { if (this.debug) console.error("SerialCLI: Error closing port", error); } finally { this.port = null; } } } /** * Start reading data from the serial port * @private */ startReading() { if (!this.reader) return; this.isReading = true; this.readLoop(); if (this.debug) console.log("SerialCLI: Started reading loop"); } /** * Main read loop for serial data * @private */ async readLoop() { while (this.isReading && this.reader) { try { const { value, done } = await this.reader.read(); if (done) { // Allow the serial port to be closed later. this.reader.releaseLock(); if (this.debug) console.log("SerialCLI: Reader stream closed"); break; } const textChunk = this.decoder.decode(value, { stream: true }); // Use stream option for potentially multi-byte chars split across chunks if (this.debug) { console.log("SerialCLI <<< RECV:", JSON.stringify(textChunk)); // Log received data } this.processIncomingData(textChunk); } catch (error) { console.error("SerialCLI: Error in read loop:", error); this.isReading = false; // Stop reading on error try { this.reader.releaseLock(); } catch (lockError) { // Ignore lock release error if already released } this.reader = null; // Consider attempting to reconnect or notify the user break; } } // Redundant check, but safe if (this.isReading && this.port?.readable && !this.reader) { try { this.reader = this.port.readable.getReader(); this.readLoop(); // Restart loop if needed and possible if (this.debug) console.log("SerialCLI: Restarted reading loop after temporary reader release"); } catch(err) { console.error("SerialCLI: Failed to re-acquire reader", err); this.isReading = false; } } else if (!this.isReading && this.debug) { console.log("SerialCLI: Reading loop stopped."); } } /** * Process incoming data from the serial port * @param {string} data - The data received from the serial port * @private */ processIncomingData(data) { this.readBuffer += data; if (this.debug) console.log("SerialCLI: Buffer:", JSON.stringify(this.readBuffer)); // Check if we're waiting for a response if (this.currentCommand) { this.checkForResponse(); } else if (this.commandQueue.length > 0) { // If no current command but queue has items, try to execute next command // This should ideally only happen after a response is fully processed // or if the device sends unsolicited data. if (this.debug) console.log("SerialCLI: Received data while idle, buffer:", JSON.stringify(this.readBuffer)); // Let's not automatically execute next command here, wait for command completion logic } } /** * Check if a complete response has been received * @private */ checkForResponse() { if (!this.currentCommand) return; // --- Refined Response Parsing Logic --- // A typical interaction looks like: // 1. Send command: `my_command\r` // 2. Device echoes: `my_command\r\n` (optional, depends on device) // 3. Device processes and sends response: ` -> OK\r\n` or multi-line for log // We need to find the "->" marker *after* the potential echo. const { command, isLogCommand } = this.currentCommand; const commandWithCR = command + '\r'; // Command as sent const commandWithCRLF = command + '\r\n'; // Potential echo format // Find the end of the command echo (could be with or without \n) let echoEndIndex = this.readBuffer.indexOf(commandWithCRLF); if (echoEndIndex !== -1) { echoEndIndex += commandWithCRLF.length; } else { echoEndIndex = this.readBuffer.indexOf(commandWithCR); if (echoEndIndex !== -1) { echoEndIndex += commandWithCR.length; } else { // Command echo might not have arrived fully yet, or device doesn't echo // Let's proceed cautiously, but this might lead to issues if echo is partial echoEndIndex = 0; // Assume start of buffer if no echo found yet if (this.debug) console.log("SerialCLI: Command echo not found yet or device doesn't echo."); } } // Look for the response marker "->" *after* the potential echo const responseMarker = " -> "; const responseStartIndex = this.readBuffer.indexOf(responseMarker, echoEndIndex); if (responseStartIndex === -1) { if (this.debug) console.log("SerialCLI: Response marker '->' not found after echo index", echoEndIndex); return; // Response marker not found yet } const responsePayloadStartIndex = responseStartIndex + responseMarker.length; // Find the end of the response (\r\n) // Search *after* the start of the response payload const newlineIndex = this.readBuffer.indexOf('\r\n', responsePayloadStartIndex); if (newlineIndex === -1) { if (this.debug) console.log("SerialCLI: Response newline not found after payload start index", responsePayloadStartIndex); return; // Full response line hasn't arrived } // Extract the response content const responseLine = this.readBuffer.substring(responsePayloadStartIndex, newlineIndex).trim(); const consumedUntilIndex = newlineIndex + 2; // Include the \r\n if (this.debug) console.log(`SerialCLI: Found response line: "${responseLine}"`); // Special handling for log command which has multi-line response ending with EOF if (isLogCommand) { // For log, the first line might just be the confirmation, e.g., "-> OK" or similar. // The actual log data follows, ending with " EOF\r\n" const eofMarker = " EOF"; // Look for EOF *after* the initial response line we just found const eofIndex = this.readBuffer.indexOf(eofMarker, consumedUntilIndex); if (eofIndex !== -1) { const eofNewlineIndex = this.readBuffer.indexOf('\r\n', eofIndex); if (eofNewlineIndex !== -1) { // Extract the log data between the first response line and the EOF marker const logData = this.readBuffer.substring(consumedUntilIndex, eofIndex).trim(); const finalConsumedIndex = eofNewlineIndex + 2; if (this.debug) console.log(`SerialCLI: Log EOF found. Log data length: ${logData.length}`); this.readBuffer = this.readBuffer.substring(finalConsumedIndex); // Consume everything including EOF line this.completeCommand(logData); // Resolve with the extracted log data } else { if (this.debug) console.log("SerialCLI: Log EOF marker found, but newline missing."); } } else { if (this.debug) console.log("SerialCLI: Log command response started, waiting for EOF."); } } else { // For standard commands, the single line is the response this.readBuffer = this.readBuffer.substring(consumedUntilIndex); // Consume the processed part this.completeCommand(responseLine); // Resolve with the single response line } if (this.debug) console.log("SerialCLI: Buffer after processing:", JSON.stringify(this.readBuffer)); } /** * Complete a command and resolve its promise with the response * @param {string} response - The response from the device * @private */ completeCommand(response) { if (!this.currentCommand) return; clearTimeout(this.currentCommand.timeout); const { resolve, command } = this.currentCommand; if (this.debug) console.log(`SerialCLI: Command "${command}" completed with response:`, response); this.currentCommand = null; resolve(response); // Schedule next command execution after a delay if (this.commandQueue.length > 0) { if (this.debug) console.log(`SerialCLI: Scheduling next command in ${this.commandDelay}ms`); setTimeout(() => this.executeNextCommand(), this.commandDelay); } else { if (this.debug) console.log("SerialCLI: Command queue empty."); } } /** * Execute the next command in the queue * @private */ async executeNextCommand() { // Prevent starting a new command if one is already in progress if (this.currentCommand) { if (this.debug) console.log("SerialCLI: executeNextCommand called, but a command is already active."); return; } if (this.commandQueue.length === 0) { if (this.debug) console.log("SerialCLI: executeNextCommand called, but queue is empty."); return; } if (!this.writer) { console.error("SerialCLI: Cannot execute command, writer is not available."); // Reject the command? Or just log and wait? Let's reject. const nextCmd = this.commandQueue.shift(); nextCmd.reject(new Error("Serial writer not available")); // Check if more commands need rejecting or if we should stop. if (this.commandQueue.length > 0) { setTimeout(() => this.executeNextCommand(), this.commandDelay); // Process next potential rejection } return; } this.currentCommand = this.commandQueue.shift(); const { command, reject } = this.currentCommand; if (this.debug) console.log(`SerialCLI: Executing command: "${command}"`); // Set response timeout this.currentCommand.timeout = setTimeout(() => { if (this.currentCommand && this.currentCommand.command === command) { // Ensure it's still the same command const timeoutMsg = `Command timeout: ${command}`; console.error("SerialCLI:", timeoutMsg); reject(new Error(timeoutMsg)); this.currentCommand = null; // Clear current command on timeout // Try the next command after a delay if (this.commandQueue.length > 0) { if (this.debug) console.log("SerialCLI: Scheduling next command after timeout."); setTimeout(() => this.executeNextCommand(), this.commandDelay); } } }, this.responseTimeout); try { const dataToSend = this.encoder.encode(command + '\r'); if (this.debug) { console.log("SerialCLI >>> SEND:", JSON.stringify(command + '\\r')); // Log data being sent } await this.writer.write(dataToSend); } catch (error) { console.error(`SerialCLI: Error writing command "${command}":`, error); clearTimeout(this.currentCommand.timeout); reject(error); this.currentCommand = null; // Clear current command on write error // Try the next command after a delay if (this.commandQueue.length > 0) { if (this.debug) console.log("SerialCLI: Scheduling next command after write error."); setTimeout(() => this.executeNextCommand(), this.commandDelay); } } } /** * Send a command to the device * @param {string} command - The command to send * @param {boolean} isLogCommand - Whether this is a log command with multi-line response ending in EOF * @returns {Promise} The device's response */ sendCommand(command, isLogCommand = false) { if (!this.port || !this.writer) { const errorMsg = 'Serial connection not open or writer unavailable'; console.error("SerialCLI:", errorMsg); return Promise.reject(new Error(errorMsg)); } return new Promise((resolve, reject) => { this.commandQueue.push({ command, resolve, reject, isLogCommand }); if (this.debug) console.log(`SerialCLI: Queued command: "${command}". Queue length: ${this.commandQueue.length}`); // If no current command is active, start execution immediately if (!this.currentCommand) { if (this.debug) console.log("SerialCLI: Triggering command execution from sendCommand."); this.executeNextCommand(); } }); } // ============= CONVENIENCE METHODS ============= /** * Get the device firmware version * @returns {Promise} Version information */ async getVersion() { return this.sendCommand('ver'); } /** * Get the current clock time * @returns {Promise} Current time */ async getClock() { return this.sendCommand('clock'); } /** * Set the time (in epoch seconds) * @param {number} seconds - Epoch seconds * @returns {Promise} Response from device */ async setTime(seconds) { // Ensure seconds is a valid number if (typeof seconds !== 'number' || !Number.isInteger(seconds) || seconds < 0) { return Promise.reject(new Error("Invalid time value. Must be a non-negative integer.")); } return this.sendCommand(`time ${seconds}`); } /** * Reboot the device * @returns {Promise} Response from device (Note: response might not be received if reboot is immediate) */ async reboot() { // Don't necessarily expect a standard response format for reboot // Consider adding a short delay after sending if needed by the calling code return this.sendCommand('reboot'); } /** * Erase filesystem (factory reset) * @returns {Promise} Response from device */ async erase() { return this.sendCommand('erase'); } /** * Force device to send an advertisement * @returns {Promise} Response from device */ async sendAdvert() { return this.sendCommand('advert'); } /** * Start OTA update * @returns {Promise} Response from device */ async startOTA() { // Might need specific node name from prefs? The C++ code suggests yes. // This JS version doesn't store prefs, so we send the basic command. // Consider adding a parameter if the node name is needed. return this.sendCommand('start ota'); } /** * Get a variable value * @param {string} variable - The variable name (e.g., 'name', 'lat', 'tx') * @returns {Promise} Raw variable value string from device (e.g., "> MyNode", "> 10", "> 433.125") */ async getVariable(variable) { return this.sendCommand(`get ${variable}`); } /** * Set a variable value * @param {string} variable - The variable name * @param {string|number|boolean} value - The value to set * @returns {Promise} Response from device (usually "OK" or an error) */ async setVariable(variable, value) { // Convert boolean 'true'/'false' to 'on'/'off' if appropriate for specific vars later return this.sendCommand(`set ${variable} ${value}`); } /** * Set admin password * @param {string} password - Admin password * @returns {Promise} Response from device */ async setPassword(password) { // Basic validation: ensure password is a non-empty string if (typeof password !== 'string' || password.length === 0) { return Promise.reject(new Error("Password cannot be empty.")); } // Potentially add checks for invalid characters if needed return this.sendCommand(`password ${password}`); } /** * Retrieve log data * @returns {Promise} Log contents (multi-line string) */ async getLog() { return this.sendCommand('log', true); // Mark as log command for multi-line EOF handling } /** * Start logging * @returns {Promise} Response from device */ async startLogging() { return this.sendCommand('log start'); } /** * Stop logging * @returns {Promise} Response from device */ async stopLogging() { return this.sendCommand('log stop'); } /** * Erase the log file * @returns {Promise} Response from device */ async eraseLog() { return this.sendCommand('log erase'); } /** * Parse response from getVariable commands, removing the "> " prefix and attempting type conversion. * @param {string} response - The raw response string from a getVariable command (e.g., "> MyNode", "> 10", "> on") * @returns {string|number|boolean|null} The parsed value, or null if parsing fails or response format is unexpected. */ parseVariableResponse(response) { if (typeof response !== 'string' || !response.startsWith('> ')) { if(this.debug) console.warn(`SerialCLI: Unexpected format for parseVariableResponse: "${response}"`); return null; // Or return the original response? Returning null indicates parsing issue. } const value = response.substring(2).trim(); // Remove "> " and trim whitespace // Check for empty value after prefix removal if (value === '') { return ''; // Return empty string if that was the actual value } // Try to parse as number (integer or float) // Updated regex to handle negative numbers and ensure it's the *entire* string if (/^-?\d+(\.\d+)?$/.test(value)) { return Number(value); // Use Number() to handle both int and float } // Handle boolean 'on'/'off' (case-insensitive) if (value.toLowerCase() === 'on') return true; if (value.toLowerCase() === 'off') return false; // Return as string for all other cases return value; } // ============= SPECIFIC VARIABLE GETTERS/SETTERS (using parseVariableResponse) ============= async getRole() { const response = await this.getVariable('role'); return this.parseVariableResponse(response); } async getPubKey() { const response = await this.getVariable('public.key'); return this.parseVariableResponse(response); } async getName() { const response = await this.getVariable('name'); return this.parseVariableResponse(response); } async setName(name) { if (typeof name !== 'string') return Promise.reject(new Error("Name must be a string.")); // Add validation for length or characters based on device limits if known return this.setVariable('name', name); } async getLatitude() { const response = await this.getVariable('lat'); return this.parseVariableResponse(response); } async setLatitude(lat) { if (typeof lat !== 'number') return Promise.reject(new Error("Latitude must be a number.")); // Add validation for range (-90 to 90) if needed return this.setVariable('lat', lat); } async getLongitude() { const response = await this.getVariable('lon'); return this.parseVariableResponse(response); } async setLongitude(lon) { if (typeof lon !== 'number') return Promise.reject(new Error("Longitude must be a number.")); // Add validation for range (-180 to 180) if needed return this.setVariable('lon', lon); } async getRadioConfig() { const response = await this.getVariable('radio'); const parsed = this.parseVariableResponse(response); if (typeof parsed === 'string') { const parts = parsed.split(','); if (parts.length === 4) { return { freq: parseFloat(parts[0]) || null, bw: parseFloat(parts[1]) || null, sf: parseInt(parts[2], 10) || null, cr: parseInt(parts[3], 10) || null }; } } if (this.debug) console.warn("SerialCLI: Could not parse radio config response:", response); return null; // Indicate parsing failure } async setRadioConfig(freq, bw, sf, cr) { // Add validation for types and ranges if necessary if (typeof freq !== 'number' || typeof bw !== 'number' || typeof sf !== 'number' || typeof cr !== 'number') { return Promise.reject(new Error("Invalid radio parameters. All must be numbers.")); } return this.setVariable('radio', `${freq},${bw},${sf},${cr}`); } async getTxPower() { const response = await this.getVariable('tx'); return this.parseVariableResponse(response); } async setTxPower(power) { if (typeof power !== 'number') return Promise.reject(new Error("TX Power must be a number.")); // Add validation for range based on device capabilities if known (e.g., 1-30) return this.setVariable('tx', power); } async getAirtimeFactor() { const response = await this.getVariable('af'); return this.parseVariableResponse(response); } async setAirtimeFactor(factor) { if (typeof factor !== 'number') return Promise.reject(new Error("Airtime factor must be a number.")); // Add validation for range (e.g., 0-9) return this.setVariable('af', factor); } async getRepeat() { const response = await this.getVariable('repeat'); return this.parseVariableResponse(response); // Should return true/false } async setRepeat(enabled) { if (typeof enabled !== 'boolean') return Promise.reject(new Error("Repeat value must be boolean (true/false).")); return this.setVariable('repeat', enabled ? 'on' : 'off'); } // Note: 'allow.read.only' is not in the C++ code provided, assuming it might exist elsewhere or is hypothetical. // If it exists and uses 'on'/'off', the pattern is the same as 'setRepeat'. // async getAllowReadOnly() { ... } // async setAllowReadOnly(enabled) { ... } async getAdvertInterval() { // C++ stores as interval/2, retrieves as interval*2 (minutes) const response = await this.getVariable('advert.interval'); return this.parseVariableResponse(response); } async setAdvertInterval(minutes) { if (typeof minutes !== 'number' || !Number.isInteger(minutes)) return Promise.reject(new Error("Advert interval must be an integer (minutes).")); // Add validation based on C++ code (min 60, max 240, or 0 for off) if (minutes !== 0 && (minutes < 60 || minutes > 240)) { return Promise.reject(new Error("Advert interval must be 0 (off) or between 60 and 240 minutes.")); } return this.setVariable('advert.interval', minutes); } // Note: 'flood.advert.interval' is not in the C++ code provided. // async getFloodAdvertInterval() { ... } // async setFloodAdvertInterval(hours) { ... } async getGuestPassword() { const response = await this.getVariable('guest.password'); return this.parseVariableResponse(response); } async setGuestPassword(password) { if (typeof password !== 'string') return Promise.reject(new Error("Guest password must be a string.")); // Consider adding length/character validation return this.setVariable('guest.password', password); } async getRxDelay() { const response = await this.getVariable('rxdelay'); return this.parseVariableResponse(response); } async setRxDelay(delay) { if (typeof delay !== 'number' || delay < 0) return Promise.reject(new Error("RX Delay must be a non-negative number.")); // Add validation for range (e.g., 0-20) return this.setVariable('rxdelay', delay); } async getTxDelay() { const response = await this.getVariable('txdelay'); return this.parseVariableResponse(response); } async setTxDelay(delay) { if (typeof delay !== 'number' || delay < 0) return Promise.reject(new Error("TX Delay factor must be a non-negative number.")); // Add validation for range (e.g., 0-2) return this.setVariable('txdelay', delay); } async getDirectTxDelay() { const response = await this.getVariable('direct.txdelay'); return this.parseVariableResponse(response); } async setDirectTxDelay(delay) { if (typeof delay !== 'number' || delay < 0) return Promise.reject(new Error("Direct TX Delay factor must be a non-negative number.")); // Add validation for range (e.g., 0-2) return this.setVariable('direct.txdelay', delay); } async getFloodMax() { const response = await this.getVariable('flood.max'); return this.parseVariableResponse(response); } async setFloodMax(max) { if (typeof max !== 'number' || !Number.isInteger(max) || max < 0 || max > 64) { return Promise.reject(new Error("Flood Max must be an integer between 0 and 64.")); } return this.setVariable('flood.max', max); } }