import log4js from "log4js"; import { WebSocketScanner } from "../network/websocketScanner.js"; import { io } from "socket.io-client"; import axios from "axios"; // Load configuration import { loadConfig } from "../config.js"; import { sendIPC } from "../desktop/notify.js"; import { PrinterManager } from "../printer/printermanager.js"; import { HostManager } from "../host/hostmanager.js"; import { FileManager } from "../files/filemanager.js"; import { DocumentPrinterManager } from "../documentprinter/documentprintermanager.js"; import { startWaiting, stopWaiting } from "../spinner.js"; const config = loadConfig(); const logger = log4js.getLogger("Socket Client"); logger.level = config.logLevel; export class SocketClient { constructor() { this.socket = null; this.authenticated = false; this.connected = false; this.loading = false; this.reconnectTimeout = null; this.hostManager = new HostManager(this); this.fileManager = new FileManager(this); this.printerManager = new PrinterManager(this); this.documentPrinterManager = new DocumentPrinterManager(this); this.scanner = new WebSocketScanner({ maxThreads: 50 }); this.readLine = null; this.objectEventCallbacks = new Map(); sendIPC("setOnline", false); sendIPC("setAuthenticated", false); sendIPC("setLoading", false); } get id() { return this.hostManager.id; } get host() { return this.hostManager.host; } setupSocketEventHandlers() { this.socket.on("connect", this.handleConnect.bind(this)); this.socket.on("connect_error", this.handleError.bind(this)); this.socket.on("objectUpdate", this.handleObjectUpdate.bind(this)); this.socket.on("objectAction", this.handleObjectAction.bind(this)); this.socket.on("objectEvent", this.handleObjectEvent.bind(this)); this.socket.on("disconnect", this.handleDisconnect.bind(this)); } scheduleReconnect() { // Clear any pending reconnect timeout if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } startWaiting("Will attempt to reconnect in 3 seconds...", logger); this.reconnectTimeout = setTimeout(async () => { await stopWaiting(); this.connect(); }, 3000); } async connect() { try { await stopWaiting(); startWaiting(`Connecting to Socket.IO server: ${config.url}`, logger); this.socket = io(config.url, { auth: { type: "host" }, reconnection: false, timeout: 3000, // 3 second timeout }); this.loading = true; sendIPC("setLoading", true); this.connected = false; sendIPC("setConnected", false); this.authenticated = false; sendIPC("setAuthenticated", false); this.setupSocketEventHandlers(); } catch (error) { await stopWaiting(); logger.error("Failed to create Socket.IO connection:", error); } } disconnect() { this.hostManager.unsubscribeFromObjectEvents(); this.socket.disconnect(); } async waitForConnection(timeoutMs = 10000) { if (this.socket?.connected) { return; } if (!this.socket) { throw new Error("Socket client is not connected"); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.socket.off("connect", onConnect); reject(new Error("Socket connection timed out")); }, timeoutMs); const onConnect = () => { clearTimeout(timeout); resolve(); }; if (this.socket.connected) { clearTimeout(timeout); resolve(); return; } this.socket.once("connect", onConnect); }); } authenticateWithOtp(otp, options) { return this.hostManager.authenticateWithOtp(otp, options); } authenticate(authenticationData, options) { return this.hostManager.authenticate(authenticationData, options); } handleObjectAction(data, callback) { logger.debug("Running object action...", data); const id = data._id; const objectType = data.objectType; const action = data.action; if (id == this.id && objectType == "host") { this.hostManager.handleAction(action, callback); } if (objectType == "printer") { this.printerManager.handlePrinterAction(id, action, callback); } if (objectType == "documentPrinter") { this.documentPrinterManager.handleDocumentPrinterAction( id, action, callback, ); } } handleObjectUpdate(data, callback) { logger.debug( "Got object update for type:", data.objectType, " id:", data._id, ); if (data.objectType == "host") { this.hostManager.handleUpdate(data); } if (data.objectType == "documentPrinter") { this.documentPrinterManager.handleDocumentPrinterUpdate( data._id, data, callback, ); } if (data.objectType == "documentSize") { this.documentPrinterManager.handleDocumentSizeUpdate(data._id, data); } if (data.objectType == "printer") { this.printerManager.handlePrinterUpdate(data._id, data, callback); } } async handleConnect() { await stopWaiting(); logger.info("Connected to FarmControl Api."); this.connected = true; sendIPC("setConnected", true); this.loading = false; sendIPC("setLoading", false); // Clear any pending reconnect timeout if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } this.hostManager.onConnect(); } async handleError(error) { await stopWaiting(); logger.error("Connection error:", error.message); this.loading = false; sendIPC("setLoading", false); this.connected = false; sendIPC("setConnected", false); this.authenticated = false; sendIPC("setAuthenticated", false); this.socket.disconnect(); this.scheduleReconnect(); } async listObjects({ objectType, populate, filter, sort, order, project, cached, }) { logger.trace("Listing objects...", { objectType, populate, filter, sort, order, project, cached, }); return new Promise((resolve, reject) => { this.socket.emit( "listObjects", { objectType, populate, filter, sort, order, project, cached, }, (result) => { if (result && result.error) { reject(new Error(result.error)); } else { logger.trace("Listed objects.", { objectType, populate, filter, sort, order, project, cached, length: result.length, }); resolve(result); } }, ); }); } async newObject({ objectType, newData }) { logger.debug("Creating object...", { objectType, newData, }); return new Promise((resolve, reject) => { this.socket.emit( "newObject", { objectType, newData, }, (result) => { if (result && result.error) { reject(new Error(result.error)); } else { logger.trace("Created object.", { objectType, newData, }); resolve(result); } }, ); }); } async editObject({ objectType, _id, populate, updateData, auditLog = true, notify = true, }) { logger.trace("Editing object...", { objectType, _id, populate, }); return new Promise((resolve, reject) => { this.socket.emit( "editObject", { objectType, _id, populate, updateData, auditLog, notify, }, (result) => { if (result && result.error) { reject(new Error(result.error)); } else { logger.trace("Edited object.", { objectType, _id, populate, }); resolve(result); } }, ); }); } async getObject({ objectType, _id, populate }) { logger.debug("Getting object...", { objectType, _id, populate, }); return new Promise((resolve, reject) => { this.socket.emit( "getObject", { objectType, _id, populate, }, (result) => { if (result && result.error) { reject(new Error(result.error)); } else { logger.trace("Retreived object.", { objectType, _id, populate, }); resolve(result); } }, ); }); } async objectEvent({ objectType, _id, eventType, eventData }) { logger.trace("Sending object event...", { objectType, _id, eventType, eventData, }); return new Promise((resolve, reject) => { this.socket.emit("objectEvent", { objectType, _id, event: { type: eventType, data: eventData, }, }); }); } getApiUrl() { return this.fileManager.getApiUrl(); } getApiHeaders() { return { "X-Auth-Code": config.host.authCode, "X-Host-Id": config.host.id || this.id, }; } parseTemplateDownloadError(error) { const data = error?.response?.data; if (data instanceof ArrayBuffer || Buffer.isBuffer(data)) { try { const parsed = JSON.parse(Buffer.from(data).toString("utf8")); return parsed?.error || error.message; } catch (parseError) { return error.message; } } return data?.error || error.message; } getObjectEventKey(objectType, _id, eventType) { return `${objectType}:${_id}:events:${eventType}`; } async downloadTemplate(templateData, type, extra = {}) { const { async: asyncMode, renderRequestId } = extra; const padding = extra.padding === true; const response = await axios.post( `${this.getApiUrl()}/documenttemplates/${templateData._id}/download`, { content: templateData.content, object: templateData.object, width: templateData.width, padding, ...(asyncMode === true ? { async: true } : {}), ...(renderRequestId ? { renderRequestId } : {}), }, { params: { type, padding: padding ? "true" : "false", ...(asyncMode === true ? { async: true } : {}), ...(renderRequestId ? { renderRequestId } : {}), }, responseType: "arraybuffer", headers: this.getApiHeaders(), }, ); const contentType = response.headers["content-type"] || ""; if (contentType.includes("application/json")) { return JSON.parse(Buffer.from(response.data).toString("utf8")); } return { type, mime: contentType, buffer: Buffer.from(response.data), }; } async renderTemplateWithProgress(templateData, type, onProgress) { const startResult = await this.downloadTemplate(templateData, type, { async: true, }); if (startResult?.error) { throw new Error(startResult.error); } if (!startResult?.renderRequestId) { return startResult; } const renderRequestId = startResult.renderRequestId; const eventType = `render:${renderRequestId}`; if (typeof onProgress === "function") { await onProgress({ progress: 0, message: "Starting render..." }); } return new Promise((resolve, reject) => { let settled = false; let unsubscribe; const finish = (result, error) => { if (settled) { return; } settled = true; clearTimeout(timeoutId); clearInterval(pollId); if (typeof unsubscribe === "function") { unsubscribe(); } if (error) { reject(error); return; } resolve(result); }; const tryFetchRenderedFile = async () => { try { const result = await this.downloadTemplate(templateData, type, { renderRequestId, }); if (result?.error) { finish(null, new Error(result.error)); return true; } finish(result); return true; } catch (error) { if (error?.response?.status === 404) { return false; } finish(null, new Error(this.parseTemplateDownloadError(error))); return true; } }; unsubscribe = this.subscribeToObjectEvent({ objectType: "render", _id: renderRequestId, eventType, callback: (event) => { if (typeof onProgress === "function") { onProgress({ progress: Number(event?.progress) || 0, message: event?.message || "", error: event?.error, }); } if (event?.error) { finish(null, new Error(event.error)); return; } if (Number(event?.progress) >= 1) { tryFetchRenderedFile(); } }, }); const pollId = setInterval(tryFetchRenderedFile, 1000); const timeoutId = setTimeout(() => { finish(null, new Error("Timed out waiting for template render.")); }, 120000); }); } async renderTemplatePDF(templateData, onProgress) { logger.debug("Rendering template PDF...", templateData); try { const result = await this.renderTemplateWithProgress( templateData, "pdf", onProgress, ); logger.trace("Template PDF rendered successfully."); if (result?.pdf) { return result; } return { pdf: result.buffer }; } catch (error) { const message = this.parseTemplateDownloadError(error) || error.message; logger.error("Failed to render template PDF:", message); throw new Error(message); } } async renderTemplateJPG(templateData, type = "jpeg", onProgress) { logger.debug(`Rendering template ${type.toUpperCase()}...`, templateData); try { const result = await this.renderTemplateWithProgress( templateData, type, onProgress, ); logger.trace("Template JPG rendered successfully."); if (Array.isArray(result?.images)) { return { images: result.images.map((image) => Buffer.isBuffer(image) ? image : Buffer.from(image, "base64"), ), }; } return { images: [result.buffer] }; } catch (error) { const message = this.parseTemplateDownloadError(error) || error.message; logger.error("Failed to render template JPG:", message); throw new Error(message); } } async subscribeToObjectActions({ objectType, _id }) { logger.debug("Suscribing to object actions...", { objectType, _id, }); this.socket.emit("subscribeToObjectActions", { objectType, _id, }); } async subscribeToObjectUpdates({ objectType, _id }) { logger.debug("Suscribing to object updates...", { objectType, _id, }); this.socket.emit("subscribeToObjectUpdates", { objectType, _id, }); } async unsubscribeFromObjectUpdates({ objectType, _id }) { logger.debug("Unsuscribing from object updates...", { objectType, _id, }); this.socket.emit("unsubscribeFromObjectUpdates", { objectType, _id, }); } subscribeToObjectEvent({ objectType, _id, eventType, callback }) { logger.debug("Suscribing to object event...", { objectType, _id, eventType, }); const emitSubscribe = () => { this.socket.emit("subscribeToObjectEvent", { objectType, _id, eventType, }); }; if (typeof callback === "function") { const key = this.getObjectEventKey(objectType, _id, eventType); if (!this.objectEventCallbacks.has(key)) { this.objectEventCallbacks.set(key, []); } const callbacks = this.objectEventCallbacks.get(key); const needsSocketSubscribe = callbacks.length === 0; callbacks.push(callback); if (needsSocketSubscribe) { emitSubscribe(); } return () => this.unsubscribeFromObjectEvent({ objectType, _id, eventType, callback, }); } emitSubscribe(); } unsubscribeFromObjectEvent({ objectType, _id, eventType, callback }) { logger.debug("Unsuscribing from object event...", { objectType, _id, eventType, }); const emitUnsubscribe = () => { this.socket.emit("unsubscribeObjectEvent", { objectType, _id, eventType, }); }; const key = this.getObjectEventKey(objectType, _id, eventType); if (typeof callback === "function" && this.objectEventCallbacks.has(key)) { const remaining = this.objectEventCallbacks .get(key) .filter((registered) => registered !== callback); if (remaining.length === 0) { this.objectEventCallbacks.delete(key); emitUnsubscribe(); } else { this.objectEventCallbacks.set(key, remaining); } return; } this.objectEventCallbacks.delete(key); emitUnsubscribe(); } async handleObjectEvent({ objectType, _id, event }) { logger.debug("Received object event...", { objectType, _id, event, }); const key = this.getObjectEventKey(objectType, _id, event?.type); const callbacks = this.objectEventCallbacks.get(key); if (Array.isArray(callbacks) && callbacks.length > 0) { callbacks.forEach((callback) => { try { callback(event); } catch (error) { logger.error("Error in object event callback:", error); } }); } const data = event.data; if ( (event.type == "childUpdate" || event.type == "childDelete" || event.type == "childNew") && objectType == "host" && _id == this.id ) { await this.hostManager.handleChildEvent(data); } } //-------------------------------------- RE-WRITE ENDS HERE --------------------------------------- async handleScanNetworkStart(data, callback) { if (this.scanner.scanning == false) { try { this.scanner = new WebSocketScanner({ maxThreads: 50 }); // Listen for found services this.scanner.on("serviceFound", (data) => { logger.info( `Found websocket service at ${data.hostname} (${data.ip})`, ); this.socket.emit("notify_scan_network_found", data); }); // Listen for scan progress this.scanner.on("scanProgress", ({ currentIP, progress }) => { logger.info( `Scanning ${currentIP} (${progress.toFixed(2)}% complete)`, ); this.socket.emit("notify_scan_network_progress", { currentIP: currentIP, progress: progress, }); }); // Start scanning on port logger.info( "Scanning network for websocket services on port:", data?.port || 7125, "using protocol:", data?.protocol || "ws", ); this.scanner .scanNetwork(data?.port || 7125, data?.protocol || "ws") .then((foundServices) => { logger.info("Scan complete. Found services:", foundServices); this.socket.emit("notify_scan_network_complete", foundServices); }) .catch((error) => { logger.error("Scan error:", error); this.socket.emit("notify_scan_network_complete", false); }); } catch (error) { logger.error("Scan error:", error); this.socket.emit("notify_scan_network_complete", false); } } } handleScanNetworkStop(callback) { if (this.scanner.scanning == true) { logger.info("Stopping network scan"); this.scanner.removeAllListeners("serviceFound"); this.scanner.removeAllListeners("scanProgress"); this.scanner.removeAllListeners("scanComplete"); this.scanner.stopScan(); callback(true); } else { logger.info("Scan not in progress"); callback(false); } } async handlePrinterObjectsQuery(data, callback) { logger.debug("Received printer.objects.query event:", data); try { const result = await this.printerManager.processPrinterCommand({ method: "printer.objects.query", params: data, }); if (callback) { callback(result); } } catch (e) { logger.error("Error processing printer objects query request:", e); if (callback) { callback({ error: e.message }); } } } async handleEmergencyStop(data, callback) { logger.debug("Received printer.gcode.script event:", data); try { const result = await this.printerManager.processPrinterCommand({ method: "printer.emergency_stop", params: data, }); if (callback) { callback(result); } } catch (e) { logger.error("Error processing gcode script request:", e); if (callback) { callback({ error: e.message }); } } } async handleFilamentStockLoad(data, callback) { logger.debug("Received printer.filamentstock.load event:", data); try { if (!data || !data.printerId) { throw new Error("Missing required printer ID"); } if (!data || !data.filamentStockId) { throw new Error("Missing required filament stock ID"); } // Get the printer client const printerClient = this.printerManager.getPrinterClient( data.printerId, ); if (!printerClient) { throw new Error(`Printer with ID ${data.printerId} not found`); } // Load the filament stock const result = await printerClient.loadFilamentStock( data.filamentStockId, ); if (callback) { callback(result); } } catch (e) { logger.error("Error processing filament load request:", e); if (callback) { callback({ error: e.message }); } } } async handleDisconnect() { logger.info("Disconnected from FarmControl Api."); await this.printerManager.closeAllConnections(); await this.documentPrinterManager.closeAllConnections(); this.connected = false; sendIPC("setConnected", false); this.authenticated = false; sendIPC("setAuthenticated", false); this.scheduleReconnect(); } }