diff --git a/flasher/configurator.html b/flasher/configurator.html
new file mode 100644
index 0000000..622f3cc
--- /dev/null
+++ b/flasher/configurator.html
@@ -0,0 +1,521 @@
+
+
+
+
+
+ MeshCore Configurator
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ snackbar.text }}
+
+
+
+ 💻 Подключение к устройству
+ Нажми «Подключиться» и выбери порт T114 в окне браузера.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Версия: {{ app.device.version || '—' }}
+
+
+ Роль: {{ app.device.role || '—' }}
+
+
+ Clock: {{ app.device.clock || '—' }}
+
+
+
+
+
+ {{ app.device.pubKey || '—' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
ⓘ Есть несохранённые изменения
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/flasher/lib/serial-cli.js b/flasher/lib/serial-cli.js
new file mode 100644
index 0000000..a075459
--- /dev/null
+++ b/flasher/lib/serial-cli.js
@@ -0,0 +1,756 @@
+/**
+ * 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);
+ }
+}
\ No newline at end of file
diff --git a/flasher/lib/vanity-key-generator.js b/flasher/lib/vanity-key-generator.js
new file mode 100644
index 0000000..13ad77d
--- /dev/null
+++ b/flasher/lib/vanity-key-generator.js
@@ -0,0 +1,118 @@
+export class VanityKeyGenerator {
+ constructor() {
+ this.workers = [];
+ this.running = false;
+ this._resolve = null;
+ this._reject = null;
+ this._totalAttempts = 0;
+ this.onProgress = null;
+ }
+
+ static get numCores() {
+ return navigator.hardwareConcurrency || 4;
+ }
+
+ /**
+ * Estimate time for a given hex prefix length
+ * @param {number} prefixLen - number of hex chars
+ * @param {number} keysPerSec - estimated throughput
+ * @returns {string} human-readable estimate
+ */
+ static estimateTime(prefixLen, keysPerSec) {
+ if (prefixLen === 0) return 'instant';
+ const expected = Math.pow(16, prefixLen);
+ const seconds = expected / keysPerSec;
+
+ if (seconds < 1) return 'less than a second';
+ if (seconds < 60) return `~${Math.ceil(seconds)} seconds`;
+ if (seconds < 3600) return `~${Math.ceil(seconds / 60)} minutes`;
+ if (seconds < 86400) return `~${Math.ceil(seconds / 3600)} hours`;
+ return `~${Math.ceil(seconds / 86400)} days`;
+ }
+
+ get attempts() {
+ return this._totalAttempts;
+ }
+
+ /**
+ * Start generating a vanity key
+ * @param {string} prefix - hex prefix to match (1-6 chars)
+ * @returns {Promise<{ privKey: string, pubKey: string, attempts: number } | null>}
+ */
+ generate(prefix) {
+ if (this.running) throw new Error('Already running');
+
+ prefix = prefix.replace(/[^0-9a-fA-F]/g, '');
+ if (prefix.length === 0 || prefix.length > 6) {
+ throw new Error('Prefix must be 1-6 hex characters');
+ }
+
+ this.running = true;
+ this._totalAttempts = 0;
+ const numWorkers = VanityKeyGenerator.numCores;
+
+ return new Promise((resolve, reject) => {
+ this._resolve = resolve;
+ this._reject = reject;
+
+ for (let i = 0; i < numWorkers; i++) {
+ const worker = new Worker(
+ new URL('./vanity-key-worker.js', import.meta.url),
+ { type: 'module' }
+ );
+
+ worker.onmessage = (e) => {
+ if (!this.running) return;
+ const data = e.data;
+
+ if (data.type === 'progress') {
+ this._totalAttempts += data.attempts;
+ if (this.onProgress) this.onProgress(this._totalAttempts);
+ } else if (data.type === 'match') {
+ this._totalAttempts += data.attempts;
+ const result = {
+ privKey: data.privKey,
+ pubKey: data.pubKey,
+ attempts: this._totalAttempts,
+ };
+ this._stopWorkers();
+ this.running = false;
+ resolve(result);
+ } else if (data.type === 'error') {
+ this._stopWorkers();
+ this.running = false;
+ reject(new Error(data.message));
+ } else if (data.type === 'stopped') {
+ this._totalAttempts += data.attempts;
+ }
+ };
+
+ worker.onerror = (err) => {
+ this._stopWorkers();
+ this.running = false;
+ reject(new Error(err.message || 'Worker error'));
+ };
+
+ worker.postMessage({ type: 'start', prefix, progressInterval: 200 });
+ this.workers.push(worker);
+ }
+ });
+ }
+
+ _stopWorkers() {
+ for (const worker of this.workers) {
+ try { worker.postMessage({ type: 'stop' }); } catch (e) {}
+ setTimeout(() => worker.terminate(), 500);
+ }
+ this.workers = [];
+ }
+
+ cancel() {
+ const reject = this._reject;
+ this._stopWorkers();
+ this.running = false;
+ if (reject) reject(new Error('Cancelled'));
+ this._resolve = null;
+ this._reject = null;
+ }
+}