Overhauled server code.
Some checks reported errors
farmcontrol/farmcontrol-server/pipeline/head Something is wrong with the build of this commit

This commit is contained in:
Tom Butcher 2025-12-28 21:46:58 +00:00
parent 5db74f2c5c
commit 7dbe7da4ee
37 changed files with 11335 additions and 11842 deletions

4
.gitignore vendored
View File

@ -135,3 +135,7 @@ build
.pnp.* .pnp.*
.nova .nova
temp_files/*
dist/*

68
Jenkinsfile vendored
View File

@ -1,55 +1,59 @@
node { def buildOnLabel(label, buildCommand) {
env.NODE_ENV = 'production' return {
node(label) {
try { stage("Checkout (${label})") {
stage('Checkout') {
checkout scm checkout scm
} }
stage('Setup Node.js') { stage("Setup Node.js (${label})") {
nodejs(nodeJSInstallationName: 'Node23') { nodejs(nodeJSInstallationName: 'Node23') {
if (isUnix()) {
sh 'node -v' sh 'node -v'
sh 'npm -v' sh 'yarn -v'
} else {
bat 'node -v'
bat 'yarn -v'
}
} }
} }
stage('Install Dependencies') { stage("Install Dependencies (${label})") {
nodejs(nodeJSInstallationName: 'Node23') { nodejs(nodeJSInstallationName: 'Node23') {
sh 'npm ci --include=dev' if (isUnix()) {
sh 'yarn install --frozen-lockfile --production=false'
} else {
bat 'yarn install --frozen-lockfile --production=false'
}
} }
} }
stage('Build') { stage("Build (${label})") {
nodejs(nodeJSInstallationName: 'Node23') { nodejs(nodeJSInstallationName: 'Node23') {
sh 'npm run build' if (isUnix()) {
sh 'ls -la build || echo "Build directory not found"' sh "NODE_ENV=production ${buildCommand}"
} else {
bat "set NODE_ENV=production && ${buildCommand}"
}
} }
} }
stage('Verify Build') { stage("Archive Artifacts (${label})") {
sh 'test -d build || (echo "Build directory does not exist" && exit 1)' archiveArtifacts artifacts: 'app_dist/**/*.dmg, app_dist/**/*.exe, app_dist/**/*.AppImage, app_dist/**/*.deb, app_dist/**/*.rpm', fingerprint: true
}
}
}
} }
stage('Deploy to printer1') { try {
def remote = [:] parallel(
remote.name = 'farmcontrolserver' 'Windows Build': buildOnLabel('windows', 'yarn build:electron'),
remote.host = 'farmcontrol.tombutcher.local' 'MacOS Build': buildOnLabel('macos', 'yarn build:electron'),
remote.user = 'ci' 'Linux Build': buildOnLabel('ubuntu', 'yarn build:electron')
remote.password = 'ci' )
remote.allowAnyHosts = true
// Copy the build directory to the remote server echo 'All parallel stages completed successfully!'
sshPut remote: remote, from: 'build/*', into: '/srv/farmcontrol-server/'
// Restart the service using sudo
sshCommand remote: remote, command: 'sudo /bin/systemctl restart farmcontrol-server.service'
}
echo 'Pipeline completed successfully!'
} catch (Exception e) { } catch (Exception e) {
echo 'Pipeline failed!' echo "Pipeline failed: ${e.message}"
throw e throw e
} finally {
cleanWs()
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

View File

@ -1,15 +1,17 @@
{ {
"development": { "development": {
"logLevel": "debug", "logLevel": "debug",
"url": "http://192.168.68.53:9090", "url": "https://dev-wss.tombutcher.work",
"apiUrl": "https://dev.tombutcher.work/api",
"host": { "host": {
"id": "68a0b5d7c873abe59a995431", "id": "691a1db49ce913faf0e51284",
"authCode": "OHHRijUj-PJnsxx6qAb7hesAlB64SdFBpDrJszComy225KIQ3M3uvMMKhdVCeGfB" "authCode": "FvD3qnNh8FP_xJShlECfYshqQawfD5oPP4xlGOFV2vQIDPRxkAjH4rO6sIgpLucX"
} }
}, },
"production": { "production": {
"logLevel": "info", "logLevel": "info",
"url": "192.168.68.53:8001", "url": "https://ws.farmcontrol.app",
"apiUrl": "https://api.farmcontrol.app",
"host": { "host": {
"id": "", "id": "",
"authCode": "" "authCode": ""

Binary file not shown.

10284
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,47 +2,101 @@
"name": "farmcontrol-server", "name": "farmcontrol-server",
"version": "1.0.0", "version": "1.0.0",
"description": "Connects to moonraker and also manages the socket connection to the printer.", "description": "Connects to moonraker and also manages the socket connection to the printer.",
"main": "src/index.js", "main": "build/index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node build/index.js", "start": "node build/index.js",
"dev": "nodemon src/index.js", "dev": "cross-env NODE_ENV=development nodemon src/index.js",
"dev:electron": "concurrently \"NODE_ENV=development electron .\" \"vite src/electron --port 5173\"", "dev:electron": "concurrently \"cross-env NODE_ENV=development vite src/electron --port 5287 --no-open\" \"cross-env NODE_ENV=development electron src/index.js\"",
"build": "rimraf build && mkdir build && cp -r src/* build/ && cp package.json config.json build/ && npm run build:electron && cp src/electron/preload.js build/electron/ && rm -rf build/electron/App.jsx build/electron/main.jsx build/electron/App.css build/electron/index.css build/electron/FarmControlLogo.jsx build/electron/vite.config.js build/electron/public build/electron/build", "build": "yarn clean && mkdir build && cp -r src/* build/ && cp package.json config.json build/ && yarn build:electron-renderer && cp src/electron/preload.js build/electron/ && rm -rf build/electron/App.jsx build/electron/main.jsx build/electron/App.css build/electron/index.css build/electron/FarmControlLogo.jsx build/electron/vite.config.js build/electron/public build/electron/build",
"build:electron": "vite build src/electron --outDir build/electron", "build:electron-renderer": "vite build src/electron --outDir build/electron",
"build:electron": "yarn build && electron-builder",
"clean": "rimraf build" "clean": "rimraf build"
}, },
"author": "Tom Butcher", "author": "Tom Butcher",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.0.0", "@ant-design/icons": "^6.1.0",
"ant-design": "^1.0.0", "ant-design": "^1.0.0",
"antd": "^5.27.0", "antd": "^5.28.0",
"axios": "^1.8.4", "axios": "^1.13.2",
"canvas": "^3.2.0",
"etcd3": "^1.1.2", "etcd3": "^1.1.2",
"express": "^5.1.0", "express": "^5.1.0",
"ipp": "^2.0.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"keycloak-connect": "^26.1.1", "keycloak-connect": "^26.1.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"log4js": "^6.9.1", "log4js": "^6.9.1",
"mongoose": "^8.13.2", "mongoose": "^9.0.0",
"node-cache": "^5.1.2",
"node-thermal-printer": "^4.5.0",
"pdf-to-img": "^5.0.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"sharp": "^0.34.5",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"ws": "^8.18.1" "ws": "^8.18.3"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.2.1", "@electron/rebuild": "^4.0.1",
"concurrently": "^9.2.0", "@vitejs/plugin-react": "^5.1.1",
"electron": "^37.2.6", "concurrently": "^9.2.1",
"jest": "^29.7.0", "cross-env": "^10.1.0",
"nodemon": "^3.1.9", "electron": "^38.7.1",
"react": "^18.2.0", "electron-builder": "^26.0.12",
"react-dom": "^18.2.0", "jest": "^30.2.0",
"rimraf": "^5.0.5", "nodemon": "^3.1.11",
"supertest": "^6.3.4", "react": "^19.2.0",
"vite": "^5.0.12", "react-dom": "^19.2.0",
"rimraf": "^6.1.2",
"supertest": "^7.1.4",
"vite": "^7.2.4",
"vite-plugin-svgo": "^2.0.0", "vite-plugin-svgo": "^2.0.0",
"vite-plugin-svgr": "^4.5.0" "vite-plugin-svgr": "^4.5.0"
},
"build": {
"appId": "com.tombutcher.farmcontrolserver",
"productName": "Farm Control Server",
"executableName": "farmcontrol-server",
"icon": "assets/farmcontrolhosticon.png",
"directories": {
"output": "app_dist"
},
"files": [
"build/**/*",
"node_modules/**/*"
],
"mac": {
"target": "dmg"
},
"win": {
"target": "nsis"
},
"linux": {
"target": [
"AppImage",
"deb",
"rpm"
],
"category": "Utility",
"maintainer": "Tom Butcher <tom@tombutcher.work>",
"executableName": "farmcontrol-server"
},
"deb": {
"priority": "optional",
"afterInstall": "packaging/linux/after-install.sh",
"afterRemove": "packaging/linux/after-remove.sh"
},
"rpm": {
"afterInstall": "packaging/linux/after-install.sh",
"afterRemove": "packaging/linux/after-remove.sh"
},
"extraResources": [
{
"from": "packaging/linux/farmcontrol-server.service",
"to": "farmcontrol-server.service"
}
]
} }
} }

View File

@ -0,0 +1,22 @@
#!/bin/bash
# Copy systemd service file
# The installation directory is usually /opt/Farm Control Server
# but we can try to find it if it's different.
APP_DIR="/opt/Farm Control Server"
if [ ! -d "$APP_DIR" ]; then
# Fallback to slugified name
APP_DIR="/opt/farmcontrol-server"
fi
cp "$APP_DIR/resources/farmcontrol-server.service" /etc/systemd/system/farmcontrol-server.service
# Reload systemd to recognize the new service
systemctl daemon-reload
# Enable the service to start on boot
systemctl enable farmcontrol-server.service
# Start the service
systemctl start farmcontrol-server.service

14
packaging/linux/after-remove.sh Executable file
View File

@ -0,0 +1,14 @@
#!/bin/bash
# Stop the service
systemctl stop farmcontrol-server.service
# Disable the service
systemctl disable farmcontrol-server.service
# Remove the systemd service file
rm -f /etc/systemd/system/farmcontrol-server.service
# Reload systemd
systemctl daemon-reload

View File

@ -0,0 +1,16 @@
[Unit]
Description=Farm Control Server
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/farmcontrol-server --headless
Restart=always
User=root
Group=root
Environment=NODE_ENV=production
WorkingDirectory=/opt/Farm Control Server
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,524 @@
// documentprinterclient.js - Handles connection to a single document printer
import { loadConfig } from "../config.js";
import log4js from "log4js";
import CupsPrinterInterface from "./interfaces/cupsinterface.js";
import ReceiptInterface from "./interfaces/receiptinterface.js";
import { sendIPC } from "../electron/ipc.js";
// Load configuration
const config = loadConfig();
const logger = log4js.getLogger("Document Printer Client");
logger.level = config.logLevel;
export class DocumentPrinterClient {
constructor(
documentPrinter = {
connection: { interface: "cups", host: "localhost", port: 9100 },
},
documentPrinterManager
) {
this.id = documentPrinter._id;
this.documentPrinter = documentPrinter;
this.connection = documentPrinter.connection;
this.queue = [];
this.documentPrinterManager = documentPrinterManager;
this.currentJob = null;
this.socketClient = documentPrinterManager.socketClient;
this.interface = documentPrinter.connection.interface || "cups"; // cups, receipt, or os
this.state = { type: "offline" };
this.isOnline = documentPrinter.online || false;
this.shouldReconnect = true;
this.isProcessingQueue = false;
this.eventUpdateInterval = null;
this.initializeInterface();
this.registerEventHandlers();
this.subscribeToActions();
this.subscribeToObjectUpdates();
}
initializeInterface() {
logger.info(
`Initializing ${this.interface} interface for document printer ${this.id}`
);
switch (this.interface) {
case "cups":
this.printerInterface = new CupsPrinterInterface(this);
logger.debug("Cups printer interface initialized");
break;
case "epsonReceipt":
this.printerInterface = new ReceiptInterface(this);
logger.debug("Epson receipt printer interface initialized");
break;
case "starReceipt":
this.printerInterface = new ReceiptInterface(this);
logger.debug("Star receipt printer interface initialized");
break;
default:
logger.error(`Unknown interface type: ${this.interface}`);
this.printerInterface = null;
}
}
async updateDocumentPrinter(data) {
logger.debug(`Updating document printer ${this.id} with data...`);
sendIPC("setDocumentPrinter", { _id: this.id, ...data });
// Check for connection changes before updating
if (data?.connection) {
const oldConnection = this.connection || {};
const newConnection = data?.connection || {};
const hostChanged =
newConnection.host != null && newConnection.host !== oldConnection.host;
const protocolChanged =
newConnection.protocol != null &&
newConnection.protocol !== oldConnection.protocol;
const interfaceChanged =
newConnection.interface != null &&
newConnection.interface !== oldConnection.interface;
const portChanged = newConnection?.port !== oldConnection?.port;
const connectionChanged =
hostChanged || interfaceChanged || portChanged || protocolChanged;
logger.debug(`Connection changed: ${connectionChanged}`);
logger.debug(`Host changed: ${hostChanged}`);
logger.debug(`Interface changed: ${interfaceChanged}`);
logger.debug(`Port changed: ${portChanged}`);
logger.debug(`Protocol changed: ${protocolChanged}`);
// Update document printer data
this.documentPrinter = { ...this.documentPrinter, ...data };
// Update interface if it changed
if (interfaceChanged) {
this.interface = newConnection.interface;
}
// Re-initialize only if connection properties changed
if (connectionChanged) {
this.connection = newConnection;
await this.printerInterface.disconnect();
this.initializeInterface();
await this.reconnect();
}
}
}
registerEventHandlers() {
// Register event handlers for document printer notifications
// This can be extended as needed
}
subscribeToActions() {
this.socketClient.subscribeToObjectActions({
objectType: "documentPrinter",
_id: this.id,
});
}
subscribeToObjectUpdates() {
this.socketClient.subscribeToObjectUpdates({
objectType: "documentPrinter",
_id: this.id,
});
}
async connect() {
logger.info(
`Connecting to document printer ${this.id} (${this.interface})`
);
clearTimeout(this.reconnectTimeout);
// Always stop the event update interval when connecting
clearInterval(this.eventUpdateInterval);
this.eventUpdateInterval = null;
this.state = { type: "connecting", message: null };
this.isOnline = false;
await this.updateDocumentPrinterState();
if (!this.printerInterface) {
logger.error(
`Cannot connect: No interface initialized for ${this.interface}`
);
return false;
}
const result = await this.printerInterface.connect();
if (result.error) {
logger.error(
`Error connecting to document printer ${this.documentPrinter.name}:`,
result.error
);
this.isOnline = false;
this.state = { type: "offline", message: result.error };
await this.updateDocumentPrinterState();
return false;
}
logger.info(
`Connected to document printer ${this.documentPrinter.name} (${this.interface})`
);
return true;
}
async reconnect() {
if (this.isOnline == true) {
logger.info(
`Disconnecting from document printer ${this.documentPrinter.name} before reconnecting...`
);
await this.disconnect();
}
logger.info(
`Reconnecting to document printer ${this.documentPrinter.name}`
);
this.shouldReconnect = true;
const connectResult = await this.connect();
if (connectResult == false) {
logger.error(
`Error reconnecting to document printer ${this.documentPrinter.name}:`,
connectResult.error
);
if (this.shouldReconnect) {
// Attempt to reconnect after delay
setTimeout(() => this.reconnect(), 30000);
}
return false;
}
const initializeResult = await this.initialize();
if (initializeResult == false) {
logger.error(
`Error initializing document printer ${this.documentPrinter.name}:`,
initializeResult.error
);
if (this.shouldReconnect) {
// Attempt to reconnect after delay
setTimeout(() => this.reconnect(), 30000);
}
return false;
}
return true;
}
async initialize() {
logger.info("Running document printer initialization...");
this.state = { type: "initializing", message: null };
this.isOnline = true;
this.connectedAt = new Date();
await this.updateDocumentPrinterState();
if (this.printerInterface && this.printerInterface.initialize) {
const result = await this.printerInterface.initialize();
if (result.error) {
logger.error(
`Error initializing document printer ${this.documentPrinter.name}:`,
result.error
);
this.state = { type: "offline", message: result.error };
await this.updateDocumentPrinterState();
return false;
}
this.state = { type: "standby", message: null };
await this.updateDocumentPrinterState();
this.eventUpdateInterval = setInterval(
this.handleEventUpdate.bind(this),
3000
);
return true;
}
}
async handleEventUpdate() {
if (this.printerInterface && this.printerInterface.retrieveStatus) {
try {
//await this.printerInterface.retrieveStatus();
} catch (error) {
logger.error(
`Error retrieving status for document printer ${this.documentPrinter.name}:`,
error
);
}
}
}
async updateDocumentPrinterState() {
try {
// Update state in database or via socket client
// This can be implemented based on your database structure
this.socketClient.editObject({
_id: this.id,
objectType: "documentPrinter",
updateData: {
online: this.isOnline,
state: this.state,
connectedAt: this.connectedAt,
},
});
} catch (error) {
logger.error(`Failed to update document printer state:`, error);
}
}
async updateJobState(jobId, state) {
logger.info(`Updating job state for ${jobId}`);
await this.socketClient.editObject({
_id: jobId,
objectType: "documentJob",
updateData: { state: state },
});
logger.info(`Updated job state for ${jobId}:`, state);
}
async deployDocumentJob(documentJob) {
logger.info(
`Deploying document job ${documentJob._id} to ${this.documentPrinter.name}`
);
if (!this.isOnline) {
logger.error(
`Cannot deploy job: Document printer not connected (${this.documentPrinter.name})`
);
return { error: "Document printer not connected" };
}
if (!this.printerInterface) {
logger.error(
`Cannot deploy job: No interface initialized (${this.documentPrinter.name})`
);
return { error: "No interface initialized" };
}
try {
if (this.printerInterface.deploy) {
await this.updateJobState(documentJob._id, { type: "deploying" });
const documentTemplate = await this.socketClient.getObject({
objectType: "documentTemplate",
_id: documentJob.documentTemplate._id,
});
await this.updateJobState(documentJob._id, {
type: "deploying",
progress: 0.25,
});
const object = await this.socketClient.getObject({
objectType: documentJob.objectType,
_id: documentJob.object._id,
});
await this.updateJobState(documentJob._id, {
type: "deploying",
progress: 0.5,
});
if (!documentTemplate) {
logger.error(
`Document template not found for job ${documentJob._id}`
);
return { error: "Document template not found" };
}
const pdfObj = await this.socketClient.renderTemplatePDF({
_id: documentTemplate._id,
content: documentTemplate.content,
object: object,
});
await this.updateJobState(documentJob._id, {
type: "deploying",
progress: 0.75,
});
if (!pdfObj) {
logger.error(
`Failed to render document template for job ${documentJob._id}`
);
return { error: "Failed to render document template" };
}
const result = await this.printerInterface.deploy(
documentJob,
pdfObj.pdf
);
await this.updateJobState(documentJob._id, {
type: "deploying",
progress: 1.0,
});
logger.info(
`Deployed document job ${documentJob._id} to ${this.documentPrinter.name}`
);
await this.updateJobState(documentJob._id, {
type: "queued",
progress: null,
});
// Only add job to queue if it's not already there
if (!this.queue.includes(documentJob._id)) {
this.queue.push(documentJob._id);
} else {
logger.warn(
`Job ${documentJob._id} is already in the queue for ${this.documentPrinter.name}`
);
}
this.startQueue();
return result;
} else {
logger.error(
`Interface ${this.interface} does not support deployDocumentJob`
);
return { error: "Interface does not support this operation" };
}
} catch (error) {
logger.error(
`Error deploying document job to ${this.documentPrinter.name}:`,
error
);
await this.updateJobState(documentJob._id, {
type: "error",
progress: null,
message: error.message || "Failed to deploy document job",
});
return { error: error.message || "Failed to deploy document job" };
}
}
async startQueue() {
if (!this.isOnline) {
logger.error(
`Cannot start queue: Document printer not connected (${this.documentPrinter.name})`
);
return { error: "Document printer not connected" };
}
if (!this.printerInterface) {
logger.error(
`Cannot start queue: No interface initialized (${this.documentPrinter.name})`
);
return { error: "No interface initialized" };
}
// Prevent concurrent queue processing
if (this.isProcessingQueue) {
logger.debug(
`Queue is already being processed for ${this.documentPrinter.name}`
);
return { info: "Queue is already being processed" };
}
if (this.state.type == "standby") {
logger.info(`Starting queue for ${this.documentPrinter.name}`);
await this.runQueue();
}
}
async runQueue() {
if (!this.isOnline) {
logger.error(
`Cannot print next job: Document printer not connected (${this.documentPrinter.name})`
);
return { error: "Document printer not connected" };
}
if (!this.printerInterface) {
logger.error(
`Cannot print next job: No interface initialized (${this.documentPrinter.name})`
);
return { error: "No interface initialized" };
}
if (this.state.type != "standby") {
logger.error(
`Cannot print next job: Document printer not in standby mode (${this.documentPrinter.name})`
);
return { error: "Document printer not in standby mode" };
}
// Prevent concurrent queue processing
if (this.isProcessingQueue) {
logger.debug(
`Queue is already being processed for ${this.documentPrinter.name}`
);
return { info: "Queue is already being processed" };
}
this.isProcessingQueue = true;
logger.info(`Starting to print jobs for ${this.documentPrinter.name}`);
this.state = { type: "printing", message: null };
await this.updateDocumentPrinterState();
try {
// Process all jobs in the queue using a loop instead of recursion
while (this.queue.length > 0) {
// Re-check connection status before each job
if (!this.isOnline) {
logger.error(
`Printer went offline while printing (${this.documentPrinter.name})`
);
this.state = {
type: "offline",
message: "Connection lost during printing",
};
await this.updateDocumentPrinterState();
return { error: "Document printer not connected" };
}
if (!this.printerInterface) {
logger.error(
`Printer interface lost while printing (${this.documentPrinter.name})`
);
this.state = {
type: "offline",
message: "Interface lost during printing",
};
await this.updateDocumentPrinterState();
return { error: "No interface initialized" };
}
// Get the next job ID but don't remove it yet
const jobId = this.queue[0];
if (!jobId) {
break;
}
try {
logger.info(`Printing job ${jobId} for ${this.documentPrinter.name}`);
await this.updateJobState(jobId, { type: "printing" });
await this.printerInterface.print(jobId);
logger.info(
`Successfully printed job ${jobId} for ${this.documentPrinter.name}`
);
// Only remove job from queue after successful printing
this.queue.shift();
await this.updateJobState(jobId, { type: "complete" });
} catch (error) {
logger.error(
`Error printing job ${jobId} for ${this.documentPrinter.name}:`,
error
);
// Remove failed job from queue to prevent infinite retry loop
// You may want to implement retry logic or error handling here
this.queue.shift();
await this.updateJobState(jobId, { type: "failed" });
// Continue with next job even if one fails
}
}
logger.info(
`Finished printing all jobs for ${this.documentPrinter.name}`
);
this.state = { type: "standby", message: null };
await this.updateDocumentPrinterState();
return true;
} finally {
// Always reset the processing flag, even if there was an error
this.isProcessingQueue = false;
}
}
async disconnect() {
logger.info(`Disconnecting from ${this.documentPrinter.name}`);
this.shouldReconnect = false;
// Always stop the event update interval when disconnecting
clearInterval(this.eventUpdateInterval);
this.eventUpdateInterval = null;
if (this.printerInterface && this.printerInterface.disconnect) {
await this.printerInterface.disconnect();
}
this.isOnline = false;
this.state = { type: "offline" };
this.isProcessingQueue = false;
this.queue = []; // Clear queue on disconnect
await this.updateDocumentPrinterState();
clearTimeout(this.reconnectTimeout);
logger.info(`Successfully disconnected from ${this.documentPrinter.name}`);
return true;
}
}

View File

@ -1,14 +1,109 @@
import { loadConfig } from "../config.js"; import { loadConfig } from "../config.js";
import log4js from "log4js"; import log4js from "log4js";
import { sendIPC } from "../electron/ipc.js";
import { DocumentPrinterClient } from "./documentprinterclient.js";
const config = loadConfig(); const config = loadConfig();
const logger = log4js.getLogger("Document Printer Manager"); const logger = log4js.getLogger("Document Printer Manager");
logger.level = config.server.logLevel; logger.level = config.logLevel;
export class DocumentPrinterManager { export class DocumentPrinterManager {
constructor(socketClient) { constructor(socketClient) {
this.socketClient = socketClient; this.socketClient = socketClient;
this.documentPrinterClients = new Map(); this.documentPrinterClients = new Map();
this.documentPrinters = [];
}
async reloadDocumentPrinters() {
logger.info("Reloading document printers...");
try {
this.documentPrinters = await this.socketClient.listObjects({
objectType: "documentPrinter",
filter: { host: this.socketClient.id },
});
sendIPC("setDocumentPrinters", this.documentPrinters);
// Remove printer clients that are no longer in the printers list
const documentPrinterIds = this.documentPrinters.map(
(documentPrinter) => documentPrinter._id
);
for (const [
documentPrinterId,
documentPrinterClient,
] of this.documentPrinterClients.entries()) {
if (!documentPrinterIds.includes(documentPrinterId)) {
// Close the connection before removing
documentPrinterClient.shouldReconnect = false;
documentPrinterClient.disconnect();
this.documentPrinterClients.delete(documentPrinterId);
logger.info(
`Removed document printer client for printer ID: ${documentPrinterId}`
);
}
}
// Add new printer clients for printers not in the documentPrinterClients map
for (const documentPrinter of this.documentPrinters) {
const documentPrinterId = documentPrinter._id;
if (!this.documentPrinterClients.has(documentPrinterId)) {
const documentPrinterClient = new DocumentPrinterClient(
documentPrinter,
this
);
this.documentPrinterClients.set(
documentPrinterId,
documentPrinterClient
);
await documentPrinterClient.reconnect();
logger.info(
`Added document printer client for printer ID: ${documentPrinterId}`
);
}
}
} catch (error) {
logger.error("Failed to update document printers:", error);
this.documentPrinters = [];
}
}
async handleDocumentPrinterAction(id, action, callback) {
logger.debug("Running document printer action...", action);
const documentPrinter = this.getDocumentPrinterClient(id);
switch (action.type) {
case "deploy":
const deployResult = await documentPrinter.deployDocumentJob(
action.data
);
callback(deployResult);
return;
}
callback({ error: "Unknown command." });
}
async handleDocumentPrinterUpdate(id, data) {
logger.debug("Handling document printer update for id:", id);
const documentPrinter = this.getDocumentPrinterClient(id);
if (documentPrinter) {
documentPrinter.updateDocumentPrinter(data.object);
}
}
getDocumentPrinterClient(documentPrinterId) {
return this.documentPrinterClients.get(documentPrinterId);
}
getAllDocumentPrinterClients() {
return this.documentPrinterClients.values();
}
// Close all printer connections
closeAllConnections() {
for (const documentPrinterClient of this.documentPrinterClients.values()) {
documentPrinterClient.shouldReconnect = false;
documentPrinterClient.disconnect();
}
this.documentPrinterClients.clear();
} }
} }

View File

@ -0,0 +1,306 @@
// cupsprinterinterface.js - CUPS printer interface implementation
import log4js from "log4js";
import { loadConfig } from "../../config.js";
import ipp from "ipp";
const config = loadConfig();
const logger = log4js.getLogger("CUPS Printer Interface");
logger.level = config.logLevel;
export default class CupsInterface {
constructor(documentPrinterClient) {
this.documentPrinterClient = documentPrinterClient;
this.host = documentPrinterClient.connection.host;
this.port = documentPrinterClient.connection.port || 631;
this.interface = documentPrinterClient.connection.interface || "cups";
this.protocol = documentPrinterClient.connection.protocol;
this.name = documentPrinterClient.documentPrinter.name;
this.isConnected = false;
this.cupsPrinter = null;
this.pdfs = new Map();
}
/**
* Build the IPP printer URL from connection settings
*/
buildPrinterUrl() {
var hostWithPort = `${this.host}:${this.port}`;
var path = "/";
if (this.host.includes("/")) {
const parts = this.host.split("/");
var hostName = parts[0];
if (hostName.includes(":")) {
const portHost = hostName.split(":");
hostName = portHost[0];
}
hostWithPort = `${hostName}:${this.port}`;
path += parts.slice(1).join("/");
}
switch (this.protocol) {
case "ipp":
return `ipp://${hostWithPort}${path}`;
case "http":
return `http://${hostWithPort}${path}`;
default:
logger.warn(`Unknown protocol ${this.protocol}, defaulting to ipp.`);
return `ipp://${hostWithPort}${path}`;
}
}
/**
* Promisify IPP execute method
*/
async executeIPP(operation, message) {
return new Promise((resolve, reject) => {
this.cupsPrinter.execute(operation, message, (err, res) => {
if (err) {
reject(err);
} else {
resolve(res);
}
});
});
}
async connect() {
logger.info(`Connecting to CUPS printer ${this.name}`);
try {
this.cupsPrinterUrl = this.buildPrinterUrl();
logger.debug(`Printer URL: ${this.cupsPrinterUrl}`);
// Create IPP printer instance
this.cupsPrinter = ipp.Printer(this.cupsPrinterUrl);
// Test connection by getting printer attributes
const getPrinterAttributes = {
"operation-attributes-tag": {
"requested-attributes": [
"printer-name",
"printer-state",
"printer-state-message",
"printer-uri-supported",
],
},
};
try {
const response = await this.executeIPP(
"Get-Printer-Attributes",
getPrinterAttributes
);
const printerState =
response["printer-attributes-tag"]?.["printer-state"];
logger.info(
`Successfully connected to CUPS printer ${this.name}. State: ${printerState}`
);
this.isConnected = true;
return true;
} catch (error) {
logger.error(
`Failed to get printer attributes for ${this.name}:`,
error
);
return {
error: "Failed to get printer attributes. " + error.message,
};
}
} catch (error) {
logger.error(`Failed to connect to CUPS printer:`, error);
this.isConnected = false;
this.cupsPrinter = null;
return {
error: "Failed to connect to CUPS printer. " + error.message,
};
}
}
async disconnect() {
logger.info(`Disconnecting from CUPS printer...`);
this.isConnected = false;
this.cupsPrinter = null;
return { success: true };
}
async initialize() {
logger.info(`Initializing CUPS printer...`);
if (!this.isConnected || !this.cupsPrinter) {
return { error: "Printer is not connected" };
}
try {
// Verify printer is ready by checking attributes
const getPrinterAttributes = {
"operation-attributes-tag": {
"requested-attributes": [
"printer-state",
"printer-state-message",
"printer-state-reasons",
],
},
};
const response = await this.executeIPP(
"Get-Printer-Attributes",
getPrinterAttributes
);
const printerState =
response["printer-attributes-tag"]?.["printer-state"];
const stateMessage =
response["printer-attributes-tag"]?.["printer-state-message"] || "";
// Printer states: 3 = idle, 4 = processing, 5 = stopped
if (printerState === 5) {
logger.warn(`Printer ${this.name} is stopped: ${stateMessage}`);
return { error: `Printer is stopped: ${stateMessage}` };
}
logger.info(
`CUPS printer ${this.name} initialized successfully. State: ${printerState}`
);
return true;
} catch (error) {
logger.error(`Failed to initialize CUPS printer ${this.name}:`, error);
return { error: error.message || "Failed to initialize CUPS printer" };
}
}
/**
* Get document data from documentJob
* Supports file references, buffers, and content
*/
async getDocumentData(documentJob) {
let data = null;
// If documentJob has a file reference, fetch it
if (documentJob.file || documentJob.fileId) {
const fileId =
documentJob.file?._id || documentJob.file || documentJob.fileId;
if (this.documentPrinterClient.socketClient?.fileManager) {
data =
await this.documentPrinterClient.socketClient.fileManager.getFile(
fileId
);
} else {
throw new Error("File manager not available to fetch file");
}
}
// If documentJob has a buffer directly
else if (documentJob.buffer || documentJob.data) {
data = documentJob.buffer || documentJob.data;
}
// If documentJob has rendered image
else if (documentJob.renderedImage) {
data = documentJob.renderedImage;
}
// If documentJob has content (text), convert to buffer
else if (documentJob.content) {
data = Buffer.from(documentJob.content, "utf-8");
} else {
throw new Error("Document job has no printable content");
}
// Ensure data is a Buffer
if (!Buffer.isBuffer(data)) {
if (data instanceof Uint8Array) {
data = Buffer.from(data);
} else if (data instanceof ArrayBuffer) {
data = Buffer.from(data);
} else if (typeof data === "string") {
data = Buffer.from(data, "utf-8");
} else {
throw new Error(
"Document data must be a Buffer, Uint8Array, ArrayBuffer, or string"
);
}
}
return data;
}
async deploy(documentJob, pdf) {
logger.info(
`Deploying job ${documentJob._id} to CUPS printer ${this.name}`
);
// Store the PDF buffer
this.pdfs.set(documentJob._id, pdf);
return { success: true };
}
async print(jobId) {
logger.info(`Printing job ${jobId} to CUPS printer ${this.name}`);
if (!this.isConnected || !this.cupsPrinter) {
throw new Error("Printer is not connected");
}
const pdf = this.pdfs.get(jobId);
if (!pdf) {
throw new Error("PDF not found for job");
}
try {
// Ensure PDF is a Buffer
let documentData = pdf;
if (!Buffer.isBuffer(documentData)) {
if (documentData instanceof Uint8Array) {
documentData = Buffer.from(documentData);
} else if (documentData instanceof ArrayBuffer) {
documentData = Buffer.from(documentData);
} else if (typeof documentData === "string") {
documentData = Buffer.from(documentData, "utf-8");
} else {
throw new Error(
"PDF data must be a Buffer, Uint8Array, ArrayBuffer, or string"
);
}
}
// Build IPP print job message
const jobName = jobId?.toString() || "Print Job";
const userName = process.env.USER || "system";
const printJobMessage = {
"operation-attributes-tag": {
"requesting-user-name": userName,
"job-name": jobName,
"document-format": "application/pdf",
},
data: documentData,
};
// Send print job
logger.debug(`Sending print job ${jobId} to ${this.cupsPrinterUrl}`);
const response = await this.executeIPP("Print-Job", printJobMessage);
// Extract job ID from response
const ippJobId = response["job-attributes-tag"]?.["job-id"];
const jobUri = response["job-attributes-tag"]?.["job-uri"];
logger.info(
`Successfully printed job ${jobId} to CUPS printer ${this.name}. IPP Job ID: ${ippJobId}`
);
return {
success: true,
jobId: ippJobId,
jobUri: jobUri,
};
} catch (error) {
logger.error(
`Failed to print job ${jobId} to CUPS printer ${this.name}:`,
error
);
return {
success: false,
error: error.message || "Failed to print job",
};
}
}
}

View File

@ -0,0 +1,200 @@
// receiptinterface.js - Thermal receipt printer interface implementation
import log4js from "log4js";
import { loadConfig } from "../../config.js";
import { ThermalPrinter, PrinterTypes } from "node-thermal-printer";
import { convertPDFToImage } from "../../utils.js";
const config = loadConfig();
const logger = log4js.getLogger("Receipt Printer Interface");
logger.level = config.logLevel;
export default class ReceiptInterface {
constructor(documentPrinterClient) {
this.documentPrinterClient = documentPrinterClient;
this.host = documentPrinterClient.connection.host;
this.name = documentPrinterClient.documentPrinter.name;
this.port = documentPrinterClient.connection.port || 9100;
this.interface =
documentPrinterClient.connection.interface || "epsonReceipt";
this.protocol = documentPrinterClient.connection.protocol;
this.isConnected = false;
this.receiptPrinter = null;
this.retrieveStatusInterval = null;
this.images = new Map();
}
buildPrinterUrl() {
switch (this.protocol) {
case "tcp":
return `tcp://${this.host}:${this.port}`;
case "system":
return `printer:${this.host}`;
case "serial":
return this.host;
default:
logger.warn(`Unknown protocol ${this.protocol}, defaulting to tcp.`);
return `tcp://${this.host}:${this.port}`;
}
}
async connect() {
try {
// Determine printer type enum
let type;
switch (this.interface) {
case "epsonReceipt":
type = PrinterTypes.EPSON;
break;
case "starReceipt":
type = PrinterTypes.STAR;
break;
default:
type = PrinterTypes.EPSON;
logger.warn(
`Unknown interface ${this.interface}, defaulting to EPSON`
);
}
// Determine interface based on connection type
const interfaceStr = this.buildPrinterUrl();
logger.info(
`Connecting to receipt printer ${this.name} (${interfaceStr})`
);
// Initialize thermal printer
this.receiptPrinter = new ThermalPrinter({
type: type,
interface: interfaceStr,
options: {
timeout: 10000,
},
});
// Test connection
const isConnected = await this.receiptPrinter.isPrinterConnected();
if (!isConnected) {
logger.error("Printer is not connected or not reachable");
return { error: "Printer is not connected or not reachable." };
}
this.isConnected = true;
logger.info(`Successfully connected to receipt printer ${this.name}`);
return true;
} catch (error) {
logger.error(`Failed to connect to receipt printer ${this.name}:`, error);
this.isConnected = false;
return {
error: "Failed to connect to receipt printer. " + error.message,
};
}
}
async disconnect() {
logger.info(`Disconnecting from receipt printer ${this.name}`);
this.isConnected = false;
this.receiptPrinter = null;
return { success: true };
}
async initialize() {
logger.info(`Initializing receipt printer ${this.name}`);
// Thermal printers typically don't need special initialization
// but we can test the connection
if (this.receiptPrinter) {
try {
const isConnected = await this.receiptPrinter.isPrinterConnected();
if (!isConnected) {
logger.error(
`Printer not connected during initialization for receipt printer ${this.name}`
);
return { error: "Printer not connected during initialization" };
}
logger.info(`Receipt printer ${this.name} initialized successfully`);
} catch (error) {
logger.error(
`Failed to initialize receipt printer ${this.name}:`,
error
);
}
}
return true;
}
async print(jobId) {
logger.info(`Printing job ${jobId} to receipt printer ${this.name}`);
if (!this.isConnected || !this.receiptPrinter) {
throw new Error("Printer is not connected");
}
try {
// Clear the printer buffer to prevent previous print jobs from being included
this.receiptPrinter.clear();
const images = this.images.get(jobId);
if (!images || (Array.isArray(images) && images.length === 0)) {
throw new Error("Image not found");
}
// convertPDFToImage returns an array of buffers, so handle multiple pages
const imageArray = Array.isArray(images) ? images : [images];
for (const image of imageArray) {
// Ensure the image is a proper Buffer and create a fresh copy
// This prevents issues with pngjs reading from consumed streams
// pdf-to-img returns PNG buffers, but we create a copy to avoid any stream issues
const imageBuffer = Buffer.isBuffer(image)
? Buffer.from(image) // Create a fresh copy
: Buffer.from(image); // Convert if needed
this.receiptPrinter.printImageBuffer(imageBuffer);
this.receiptPrinter.cut();
}
// Execute the print job
await this.receiptPrinter.execute({ waitForResponse: true });
logger.info(
`Successfully printed job ${jobId} to receipt printer ${this.name}`
);
return { success: true };
} catch (error) {
logger.error(
`Failed to print job ${jobId} to receipt printer ${this.name}:`,
error
);
return { success: false, error: error.message };
}
}
async deploy(documentJob, pdf) {
logger.info(
`Deploying job ${documentJob._id} to receipt printer ${this.name}`
);
const images = await convertPDFToImage(pdf, { width: 512 });
// Store the array of image buffers
this.images.set(documentJob._id, images);
return { success: true };
}
async retrieveStatus() {
logger.debug(`Getting status of receipt printer ${this.name}`);
if (this.isOnline == false) {
logger.error("Printer is not connected or not reachable");
return { error: "Printer is not connected or not reachable." };
}
try {
const status = await this.receiptPrinter.raw(
Buffer.from([0x10, 0x04, 0x04])
);
logger.info(`Printer status: ${status}`);
} catch (error) {
logger.error(`Failed to execute printer status:`, error);
}
logger.debug(`Receipt printer ${this.name} is connected.`);
return true;
}
}

View File

@ -1,32 +1,28 @@
import "./App.css"; import "./App.css";
import { import { Flex, Button, Tag, Menu, ConfigProvider, theme, Layout } from "antd";
Flex,
Button,
Typography,
Tag,
Menu,
ConfigProvider,
theme,
Layout,
Modal,
} from "antd";
import { MenuOutlined } from "@ant-design/icons"; import { MenuOutlined } from "@ant-design/icons";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import merge from "lodash/merge"; import _ from "lodash";
import unionBy from "lodash/unionBy"; import unionBy from "lodash/unionBy";
import Overview from "./pages/Overview"; import Overview from "./pages/Overview";
import Printers from "./pages/Printers"; import Printers from "./pages/Printers";
import DocumentPrinters from "./pages/DocumentPrinters";
import Loading from "./pages/Loading"; import Loading from "./pages/Loading";
import OTPInput from "./pages/OTPInput"; import OTPInput from "./pages/OTPInput";
import CloudIcon from "./icons/CloudIcon"; import CloudIcon from "./icons/CloudIcon";
import LockIcon from "./icons/LockIcon"; import LockIcon from "./icons/LockIcon";
import SettingsIcon from "./icons/SettingsIcon"; import SettingsIcon from "./icons/SettingsIcon";
import Disconnected from "./pages/Disconnected"; import Disconnected from "./pages/Disconnected";
import Files from "./pages/Files";
import HostIcon from "./icons/HostIcon";
import PrinterIcon from "./icons/PrinterIcon";
import DocumentPrinterIcon from "./icons/DocumentPrinterIcon";
import FileIcon from "./icons/FileIcon";
const App = () => { const App = () => {
const [host, setHost] = useState({}); const [host, setHost] = useState({});
const [printers, setPrinters] = useState([]); const [printers, setPrinters] = useState([]);
const [documentPrinters, setDocumentPrinters] = useState([]); const [documentPrinters, setDocumentPrinters] = useState([]);
const [files, setFiles] = useState([]);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [authenticated, setAuthenticated] = useState(false); const [authenticated, setAuthenticated] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@ -51,29 +47,45 @@ const App = () => {
// Set up IPC listeners when component mounts // Set up IPC listeners when component mounts
window.electronAPI.onIPCData("setHost", (newHost) => { window.electronAPI.onIPCData("setHost", (newHost) => {
console.log("Host data received:", newHost); console.log("Host data received:", newHost);
setHost((prev) => merge(prev, newHost)); setHost((prev) => _.merge(prev, newHost));
});
window.electronAPI.onIPCData("setFiles", (newFiles) => {
console.log("Files data received:", newFiles);
setFiles(newFiles);
}); });
window.electronAPI.onIPCData("setPrinters", (newPrinters) => { window.electronAPI.onIPCData("setPrinters", (newPrinters) => {
console.log("Printers data:", newPrinters);
setPrinters(newPrinters); setPrinters(newPrinters);
}); });
window.electronAPI.onIPCData("setPrinter", (newPrinter) => { window.electronAPI.onIPCData("setPrinter", (newPrinter) => {
console.log("Printer data:", newPrinter); setPrinters((prev) =>
setPrinters((prev) => unionBy(prev, [newPrinter], "_id")); prev.map((printer) =>
printer._id === newPrinter._id
? _.merge(printer, newPrinter)
: printer
)
);
}); });
window.electronAPI.onIPCData( window.electronAPI.onIPCData(
"setDocumentPrinters", "setDocumentPrinters",
(newDocumentPrinters) => { (newDocumentPrinters) => {
console.log("Document printers data:", newDocumentPrinters); setDocumentPrinters(newDocumentPrinters);
setDocumentPrinters((prev) =>
unionBy(prev, newDocumentPrinters, "_id")
);
} }
); );
window.electronAPI.onIPCData("setDocumentPrinter", (newDocumentPrinter) => {
setDocumentPrinters((prev) =>
prev.map((documentPrinter) =>
documentPrinter._id === newDocumentPrinter._id
? _.merge(documentPrinter, newDocumentPrinter)
: documentPrinter
)
);
});
window.electronAPI.onIPCData("setAuthenticated", (setAuthenticated) => { window.electronAPI.onIPCData("setAuthenticated", (setAuthenticated) => {
console.log("Set authenticated:", setAuthenticated); console.log("Set authenticated:", setAuthenticated);
setLoading(setAuthenticated); setLoading(setAuthenticated);
@ -107,13 +119,24 @@ const App = () => {
return () => { return () => {
window.electronAPI.removeAllListeners("setHost"); window.electronAPI.removeAllListeners("setHost");
window.electronAPI.removeAllListeners("setPrinters"); window.electronAPI.removeAllListeners("setPrinters");
window.electronAPI.removeAllListeners("setPrinter");
window.electronAPI.removeAllListeners("setDocumentPrinters"); window.electronAPI.removeAllListeners("setDocumentPrinters");
window.electronAPI.removeAllListeners("setDocumentPrinter");
window.electronAPI.removeAllListeners("setAuthenticated"); window.electronAPI.removeAllListeners("setAuthenticated");
window.electronAPI.removeAllListeners("setFiles");
window.electronAPI.removeAllListeners("setConnected"); window.electronAPI.removeAllListeners("setConnected");
window.electronAPI.removeAllListeners("setLoading"); window.electronAPI.removeAllListeners("setLoading");
}; };
}, []); // Empty dependency array means this runs once on mount }, []); // Empty dependency array means this runs once on mount
useEffect(() => {
console.log("Document printers:", documentPrinters);
}, [documentPrinters]);
useEffect(() => {
console.log("Printers:", printers);
}, [printers]);
// Function to render the appropriate page based on currentPageKey and auth status // Function to render the appropriate page based on currentPageKey and auth status
const renderCurrentPage = () => { const renderCurrentPage = () => {
// If loading, show loading // If loading, show loading
@ -134,11 +157,20 @@ const App = () => {
// If authenticated and connected, show the selected page // If authenticated and connected, show the selected page
switch (currentPageKey) { switch (currentPageKey) {
case "overview": case "overview":
return <Overview printers={printers} host={host} loading={loading} />; return (
<Overview
printers={printers}
host={host}
loading={loading}
documentPrinters={documentPrinters}
/>
);
case "printers": case "printers":
return <Printers printers={printers} />; return <Printers printers={printers} />;
case "files":
return <Files files={files} />;
case "documentPrinters": case "documentPrinters":
return <div>Document Printers Page (to be implemented)</div>; return <DocumentPrinters documentPrinters={documentPrinters} />;
default: default:
return <Overview />; return <Overview />;
} }
@ -153,14 +185,22 @@ const App = () => {
{ {
key: "overview", key: "overview",
label: "Overview", label: "Overview",
icon: <HostIcon />,
}, },
{ {
key: "printers", key: "printers",
label: "Printers", label: "Printers",
icon: <PrinterIcon />,
}, },
{ {
key: "documentPrinters", key: "documentPrinters",
label: "Document Printers", label: "Document Printers",
icon: <DocumentPrinterIcon />,
},
{
key: "files",
label: "Files",
icon: <FileIcon />,
}, },
]; ];
@ -186,10 +226,18 @@ const App = () => {
<Layout> <Layout>
<Flex style={{ width: "100vw", height: "100vh" }} vertical> <Flex style={{ width: "100vw", height: "100vh" }} vertical>
<Flex <Flex
className="ant-menu-horizontal ant-menu-light" className="ant-menu-horizontal ant-menu-light electron-drag-area"
style={{ lineHeight: "40px", padding: "0 8px 0 75px" }} style={{
lineHeight: "40px",
padding: "0 8px 0 75px",
}}
justify="space-between"
> >
{loading == false && authenticated == true && connected == true ? ( {loading == false && authenticated == true && connected == true ? (
<div
className="electron-navigation"
style={{ width: "max-content" }}
>
<Menu <Menu
mode="horizontal" mode="horizontal"
items={mainMenuItems} items={mainMenuItems}
@ -197,17 +245,16 @@ const App = () => {
style={{ style={{
flexWrap: "wrap", flexWrap: "wrap",
border: 0, border: 0,
lineHeight: "40px", lineHeight: "38px",
}} }}
overflowedIndicator={ overflowedIndicator={
<Button type="text" icon={<MenuOutlined />} /> <Button type="text" icon={<MenuOutlined />} />
} }
onClick={handleMenuClick} onClick={handleMenuClick}
/> />
</div>
) : null} ) : null}
<Flex align="center" gap={"small"} className="electron-navigation">
<div className="electron-navigation" style={{ flexGrow: 1 }}></div>
<Flex align="center" gap={"small"}>
<Button <Button
type="text" type="text"
icon={<SettingsIcon />} icon={<SettingsIcon />}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.156042,0,0,0.156042,1.80453,0.721021)">
<rect x="0" y="0" width="53.774" height="67.661" style="fill-opacity:0;"/>
<path d="M10.149,67.641L43.438,67.641C50.137,67.641 53.586,64.135 53.586,57.41L53.586,29.058C53.586,24.717 53.028,22.725 50.308,19.954L33.894,3.284C31.282,0.616 29.111,0 25.212,0L10.149,0C3.481,0 0,3.532 0,10.257L0,57.41C0,64.161 3.455,67.641 10.149,67.641ZM10.637,61.519C7.621,61.519 6.122,59.941 6.122,57.029L6.122,10.637C6.122,7.752 7.621,6.122 10.663,6.122L23.984,6.122L23.984,23.261C23.984,27.733 26.167,29.895 30.619,29.895L47.464,29.895L47.464,57.029C47.464,59.941 45.965,61.519 42.929,61.519L10.637,61.519ZM31.198,24.496C29.903,24.496 29.384,23.945 29.384,22.656L29.384,6.973L46.613,24.496L31.198,24.496Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,57 @@
import React from "react";
import PropTypes from "prop-types";
import { Typography, List, Button, Flex, Tag } from "antd";
import FileIcon from "../icons/FileIcon";
import InfoCircleIcon from "../icons/InfoCircleIcon";
import StateDisplay from "./StateDisplay";
import MissingPlaceholder from "./MissingPlaceholder";
const { Text } = Typography;
const FileList = ({ files, type = "file" }) => {
if ((files?.length || 0) <= 0) {
return (
<MissingPlaceholder
message={`No ${type == "file" ? "files" : "document files"} added.`}
/>
);
}
return (
<List
dataSource={files}
size="small"
bordered
renderItem={(file) => (
<List.Item actions={[]}>
<List.Item.Meta
description={
<Flex gap={"middle"} justify="space-between" align="center">
<Flex gap={"small"}>
<Text>
<FileIcon />
</Text>
<Text>{file.name || file._id}</Text>
<Tag>{file?.extension ? file.extension : "Unknown"}</Tag>
</Flex>
<Flex gap={"middle"} align="center">
<StateDisplay state={file.state} showProgress={true} />
<Button
key="info"
icon={<InfoCircleIcon />}
type="text"
size="small"
/>
</Flex>
</Flex>
}
/>
</List.Item>
)}
/>
);
};
FileList.propTypes = {
files: PropTypes.array.isRequired,
};
export default FileList;

View File

@ -1,36 +1,36 @@
// PrinterSelect.js // PrinterSelect.js
import PropTypes from 'prop-types' import PropTypes from "prop-types";
import { Progress, Flex, Space } from 'antd' import { Progress, Flex, Space } from "antd";
import StateTag from './StateTag' import StateTag from "./StateTag";
const StateDisplay = ({ state, showProgress = true, showState = true }) => { const StateDisplay = ({ state, showProgress = true, showState = true }) => {
const currentState = state || { const currentState = state || {
type: 'unknown', type: "unknown",
progress: 0 progress: 0,
} };
return ( return (
<Flex gap='small' align={'center'}> <Flex gap="small" align={"center"}>
{showState && ( {showState && (
<Space> <Space>
<StateTag state={currentState.type} /> <StateTag state={currentState.type} />
</Space> </Space>
)} )}
{showProgress && currentState?.progress && currentState?.progress > 0 ? ( {showProgress && currentState?.percent > 0 ? (
<Progress <Progress
percent={Math.round(currentState.progress * 100)} percent={Math.round(currentState.percent)}
status='active' status="active"
style={{ width: '150px', marginBottom: '2px' }} style={{ width: "150px", marginBottom: "2px" }}
/> />
) : null} ) : null}
</Flex> </Flex>
) );
} };
StateDisplay.propTypes = { StateDisplay.propTypes = {
state: PropTypes.object, state: PropTypes.object,
showProgress: PropTypes.bool, showProgress: PropTypes.bool,
showState: PropTypes.bool showState: PropTypes.bool,
} };
export default StateDisplay export default StateDisplay;

View File

@ -1,92 +1,100 @@
import PropTypes from 'prop-types' import PropTypes from "prop-types";
import { Badge, Flex, Tag } from 'antd' import { Badge, Flex, Tag } from "antd";
import { useMemo } from 'react' import { useMemo } from "react";
const StateTag = ({ state, showBadge = true, style = {} }) => { const StateTag = ({ state, showBadge = true, style = {} }) => {
const { badgeStatus, badgeText } = useMemo(() => { const { badgeStatus, badgeText } = useMemo(() => {
let status = 'default' let status = "default";
let text = 'Unknown' let text = "Unknown";
switch (state) { switch (state) {
case 'online': case "online":
status = 'success' status = "success";
text = 'Online' text = "Online";
break break;
case 'standby': case "standby":
status = 'success' status = "success";
text = 'Standby' text = "Standby";
break break;
case 'complete': case "complete":
status = 'success' status = "success";
text = 'Complete' text = "Complete";
break break;
case 'offline': case "offline":
status = 'default' status = "default";
text = 'Offline' text = "Offline";
break break;
case 'shutdown': case "shutdown":
status = 'default' status = "default";
text = 'Shutdown' text = "Shutdown";
break break;
case 'initializing': case "initializing":
status = 'warning' status = "warning";
text = 'Initializing' text = "Initializing";
break break;
case 'printing': case "printing":
status = 'processing' status = "processing";
text = 'Printing' text = "Printing";
break break;
case 'paused': case "paused":
status = 'warning' status = "warning";
text = 'Paused' text = "Paused";
break break;
case 'cancelled': case "cancelled":
status = 'error' status = "error";
text = 'Cancelled' text = "Cancelled";
break break;
case 'loading': case "loading":
status = 'processing' status = "processing";
text = 'Uploading' text = "Uploading";
break break;
case 'processing': case "processing":
status = 'processing' status = "processing";
text = 'Processing' text = "Processing";
break break;
case 'ready': case "ready":
status = 'success' status = "success";
text = 'Ready' text = "Ready";
break break;
case 'unconsumed': case "unconsumed":
status = 'success' status = "success";
text = 'Unconsumed' text = "Unconsumed";
break break;
case 'error': case "error":
status = 'error' status = "error";
text = 'Error' text = "Error";
break break;
case 'startup': case "startup":
status = 'warning' status = "warning";
text = 'Startup' text = "Startup";
break break;
case 'draft': case "draft":
status = 'default' status = "default";
text = 'Draft' text = "Draft";
break break;
case 'failed': case "failed":
status = 'error' status = "error";
text = 'Failed' text = "Failed";
break break;
case 'queued': case "queued":
status = 'warning' status = "warning";
text = 'Queued' text = "Queued";
break break;
case "downloading":
status = "processing";
text = "Downloading";
break;
case "downloaded":
status = "success";
text = "Downloaded";
break;
default: default:
status = 'default' status = "default";
text = state || 'Unknown' text = state || "Unknown";
} }
return { badgeStatus: status, badgeText: text } return { badgeStatus: status, badgeText: text };
}, [state]) }, [state]);
return ( return (
<Tag color={badgeStatus} style={{ marginRight: 0, ...style }}> <Tag color={badgeStatus} style={{ marginRight: 0, ...style }}>
@ -95,13 +103,13 @@ const StateTag = ({ state, showBadge = true, style = {} }) => {
{badgeText} {badgeText}
</Flex> </Flex>
</Tag> </Tag>
) );
} };
StateTag.propTypes = { StateTag.propTypes = {
state: PropTypes.string, state: PropTypes.string,
showBadge: PropTypes.bool, showBadge: PropTypes.bool,
style: PropTypes.object style: PropTypes.object,
} };
export default StateTag export default StateTag;

View File

@ -0,0 +1,6 @@
import Icon from "@ant-design/icons";
import CustomIconSvg from "../assets/icons/fileicon.svg?react";
const FileIcon = (props) => <Icon component={CustomIconSvg} {...props} />;
export default FileIcon;

View File

@ -21,8 +21,14 @@ button {
font-family: inherit; font-family: inherit;
} }
/* Ensure proper theme support */ .electron-navigation {
#root { -webkit-app-region: no-drag;
width: 100%; }
height: 100%;
.electron-drag-area {
-webkit-app-region: drag;
}
.electron-drag-area:after {
display: none;
} }

View File

@ -6,11 +6,6 @@
<link rel="stylesheet" href="./fonts.css" /> <link rel="stylesheet" href="./fonts.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Farm Control Server</title> <title>Farm Control Server</title>
<style>
.electron-navigation {
-webkit-app-region: drag;
}
</style>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -21,8 +21,7 @@ export async function setupIPC() {
logger.warn("ipcMain not available, skipping IPC setup"); logger.warn("ipcMain not available, skipping IPC setup");
return; return;
} }
// Generic IPC handler for custom messages
console.log("SETTING GET DATA HANDLER");
ipcMain.on("getData", (event) => { ipcMain.on("getData", (event) => {
logger.info("Getting data..."); logger.info("Getting data...");
try { try {
@ -35,6 +34,7 @@ export async function setupIPC() {
sendIPC("setLoading", false); sendIPC("setLoading", false);
sendIPC("setHost", {}); sendIPC("setHost", {});
sendIPC("setPrinters", []); sendIPC("setPrinters", []);
sendIPC("setDocumentPrinters", []);
return; return;
} }
@ -44,6 +44,10 @@ export async function setupIPC() {
sendIPC("setLoading", socketClient.loading); sendIPC("setLoading", socketClient.loading);
sendIPC("setHost", socketClient.host || {}); sendIPC("setHost", socketClient.host || {});
sendIPC("setPrinters", socketClient.printerManager.printers || []); sendIPC("setPrinters", socketClient.printerManager.printers || []);
sendIPC(
"setDocumentPrinters",
socketClient.documentPrinterManager.documentPrinters || []
);
} catch (error) { } catch (error) {
logger.error("Error getting printer data:", error); logger.error("Error getting printer data:", error);
sendIPC("setAuthenticated", false); sendIPC("setAuthenticated", false);

View File

@ -0,0 +1,34 @@
import React from "react";
import { Space, Card, Flex } from "antd";
import DocumentPrinterIcon from "../icons/DocumentPrinterIcon.jsx";
import PrinterList from "../components/PrinterList.jsx";
const DocumentPrinters = ({ documentPrinters }) => {
return (
<Flex
vertical
size="large"
style={{ width: "100%", height: "100%" }}
gap={"middle"}
>
<Flex gap={"middle"} wrap style={{ flexGrow: 1 }}>
<Card
size="small"
style={{ minWidth: "400px", flexGrow: 1 }}
title={
<Space>
<DocumentPrinterIcon />
Document Printers
</Space>
}
>
<PrinterList printers={documentPrinters} type="documentPrinter" />
</Card>
</Flex>
</Flex>
);
};
DocumentPrinters.propTypes = {};
export default DocumentPrinters;

View File

@ -0,0 +1,34 @@
import React from "react";
import { Card, Flex, Space } from "antd";
import FileList from "../components/FileList.jsx";
import FileIcon from "../icons/FileIcon";
const Files = ({ files }) => {
return (
<Flex
vertical
size="large"
style={{ width: "100%", height: "100%" }}
gap={"middle"}
>
<Flex gap={"middle"} wrap style={{ flexGrow: 1 }}>
<Card
size="small"
style={{ minWidth: "400px", flexGrow: 1 }}
title={
<Space>
<FileIcon />
Files
</Space>
}
>
<FileList files={files} type="file" />
</Card>
</Flex>
</Flex>
);
};
Files.propTypes = {};
export default Files;

View File

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Space, Card, Flex } from "antd"; import { Space, Card, Flex, Button } from "antd";
import PrinterIcon from "../icons/PrinterIcon.jsx"; import PrinterIcon from "../icons/PrinterIcon.jsx";
import PrinterList from "../components/PrinterList.jsx"; import PrinterList from "../components/PrinterList.jsx";
@ -12,7 +12,16 @@ const Printers = ({ printers }) => {
gap={"middle"} gap={"middle"}
> >
<Flex gap={"middle"} wrap style={{ flexGrow: 1 }}> <Flex gap={"middle"} wrap style={{ flexGrow: 1 }}>
<Card size="small" style={{ minWidth: "400px", flexGrow: 1 }}> <Card
size="small"
style={{ minWidth: "400px", flexGrow: 1 }}
title={
<Space>
<PrinterIcon />
Printers
</Space>
}
>
<PrinterList printers={printers} type="printer" /> <PrinterList printers={printers} type="printer" />
</Card> </Card>
</Flex> </Flex>

View File

@ -17,6 +17,6 @@ export default defineConfig({
}, },
}, },
server: { server: {
port: 5173, port: 5287,
}, },
}); });

View File

@ -62,7 +62,7 @@ export async function createElectronWindow() {
logger.info("Preload Script", path.join(__dirname, "preload.js")); logger.info("Preload Script", path.join(__dirname, "preload.js"));
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
logger.info("Loading development url..."); logger.info("Loading development url...");
win.loadURL("http://localhost:5173"); // Vite dev server win.loadURL("http://localhost:5287"); // Vite dev server
} else { } else {
// In production, the built files will be in the build/electron directory // In production, the built files will be in the build/electron directory
win.loadFile( win.loadFile(

264
src/files/filemanager.js Normal file
View File

@ -0,0 +1,264 @@
// filemanager.js - Manages file downloads and caching
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import axios from "axios";
import log4js from "log4js";
import _ from "lodash";
import { loadConfig } from "../config.js";
import { sendIPC } from "../electron/ipc.js";
const config = loadConfig();
const logger = log4js.getLogger("File Manager");
logger.level = config.logLevel;
// Configure paths relative to this file
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TEMP_FILES_DIR = path.resolve(__dirname, "../../temp_files");
export class FileManager {
constructor(socketClient) {
this.socketClient = socketClient;
this.files = [];
this.downloadingFiles = new Map(); // Track ongoing downloads by fileId
this.ensureTempFilesDirectory();
this.progressCallbacks = new Map();
}
/**
* Ensure the temp_files directory exists
*/
ensureTempFilesDirectory() {
try {
if (!fs.existsSync(TEMP_FILES_DIR)) {
fs.mkdirSync(TEMP_FILES_DIR, { recursive: true });
logger.info(`Created temp_files directory at ${TEMP_FILES_DIR}`);
}
} catch (error) {
logger.error(`Failed to create temp_files directory: ${error.message}`);
}
}
/**
* Update the files list from the host's files property
*/
async updateFiles() {
const host = await this.socketClient.getObject({
objectType: "host",
_id: this.socketClient.host._id,
populate: ["files"],
});
this.files = (host.files || []).map((file) => {
return { ...file, state: { type: "downloaded" } };
});
sendIPC("setFiles", this.files);
logger.debug(`Updated files list: ${this.files.length} files`);
}
/**
* Get the API URL for downloading files
* Converts the socket URL to HTTP/HTTPS URL
*/
getApiUrl() {
let apiUrl = config.apiUrl;
if (!apiUrl) {
throw new Error("API URL not configured");
}
return apiUrl;
}
/**
* Get the file path in temp_files directory
*/
getFilePath(fileId) {
return path.join(TEMP_FILES_DIR, fileId);
}
/**
* Check if file exists in temp_files directory
*/
fileExistsInCache(fileId) {
const filePath = this.getFilePath(fileId);
return fs.existsSync(filePath);
}
/**
* Get file from cache
*/
getFileFromCache(fileId) {
const filePath = this.getFilePath(fileId);
try {
const fileBuffer = fs.readFileSync(filePath);
logger.debug(`Retrieved file ${fileId} from cache`);
return fileBuffer;
} catch (error) {
logger.error(`Failed to read file from cache: ${error.message}`);
throw error;
}
}
/**
* Save file to cache
*/
saveFileToCache(fileId, fileBuffer) {
const filePath = this.getFilePath(fileId);
try {
fs.writeFileSync(filePath, fileBuffer);
logger.debug(`Saved file ${fileId} to cache`);
} catch (error) {
logger.error(`Failed to save file to cache: ${error.message}`);
throw error;
}
}
/**
* Download file from API
*/
async downloadFileFromApi(fileId) {
const apiUrl = this.getApiUrl();
const downloadUrl = `${apiUrl}/files/${fileId}/content/`;
const fileObject = await this.socketClient.getObject({
objectType: "file",
_id: fileId,
});
this.files = [
...this.files,
{ ...fileObject, state: { type: "downloading", percent: 0 } },
];
sendIPC("setFiles", this.files);
logger.info(`Downloading file ${fileId} from ${downloadUrl}`);
try {
const response = await axios.get(downloadUrl, {
responseType: "arraybuffer", // Get binary data
headers: {
"X-Auth-Code": config.host.authCode,
"X-Host-Id": config.host.id,
},
onDownloadProgress: (progressEvent) => {
console.log(progressEvent);
const percent = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
logger.debug(`Downloading file ${fileId}: ${percent}%`);
this.files = _.unionBy(
[{ ...fileObject, state: { type: "downloading", percent } }],
this.files,
"_id"
);
sendIPC("setFiles", this.files);
if (this.progressCallbacks.has(fileId)) {
const progressCallbacks = this.progressCallbacks.get(fileId);
for (const callback of progressCallbacks) {
callback(percent);
}
}
},
});
const fileBuffer = Buffer.from(response.data);
logger.info(`Successfully downloaded file ${fileId}`);
this.saveFileToCache(fileId, fileBuffer);
this.files = _.unionBy(
[{ ...fileObject, state: { type: "downloaded" } }],
this.files,
"_id"
);
if (this.progressCallbacks.has(fileId)) {
const progressCallbacks = this.progressCallbacks.get(fileId);
for (const callback of progressCallbacks) {
callback(100);
}
this.progressCallbacks.delete(fileId);
}
this.socketClient.editObject({
objectType: "host",
_id: this.socketClient.host._id,
updateData: {
files: this.files.map((file) => file._id),
},
});
sendIPC("setFiles", this.files);
return fileBuffer;
} catch (error) {
logger.error(`Failed to download file ${fileId}: ${error.message}`);
if (error.response) {
logger.error(`Response status: ${error.response.status}`);
logger.error(`Response data: ${error.response.data}`);
}
this.files = _.unionBy(
[{ ...fileObject, state: { type: "error" } }],
this.files,
"_id"
);
sendIPC("setFiles", this.files);
}
}
/**
* Get file by ID
* Checks temp_files directory first, then downloads from API if not found
* If a download is already in progress for the same fileId, waits for that download
*/
async getFile(fileId, onProgress) {
if (!fileId) {
throw new Error("File ID is required");
}
// Update files list from host
this.updateFiles();
if (onProgress) {
const progressCallbacks = this.progressCallbacks.get(fileId) || [];
progressCallbacks.push(onProgress);
this.progressCallbacks.set(fileId, progressCallbacks);
}
// Check if file exists in cache
if (this.fileExistsInCache(fileId)) {
logger.debug(`File ${fileId} found in cache`);
if (this.progressCallbacks.has(fileId)) {
const progressCallbacks = this.progressCallbacks.get(fileId);
for (const callback of progressCallbacks) {
callback(100);
}
}
return this.getFileFromCache(fileId);
}
// Check if download is already in progress for this file
if (this.downloadingFiles.has(fileId)) {
logger.debug(`File ${fileId} download already in progress, waiting...`);
try {
return await this.downloadingFiles.get(fileId);
} catch (error) {
// If the download failed, remove it from the map so it can be retried
this.downloadingFiles.delete(fileId);
throw error;
}
}
// File not in cache and no download in progress, start new download
logger.debug(`File ${fileId} not in cache, downloading from API`);
const downloadPromise = this.downloadFileFromApi(fileId)
.then((fileBuffer) => {
// Remove from downloading map on success
this.downloadingFiles.delete(fileId);
return fileBuffer;
})
.catch((error) => {
// Remove from downloading map on error
this.downloadingFiles.delete(fileId);
throw error;
});
// Store the promise in the map
this.downloadingFiles.set(fileId, downloadPromise);
return await downloadPromise;
}
}

View File

@ -10,7 +10,10 @@ const config = loadConfig();
const logger = log4js.getLogger("App"); const logger = log4js.getLogger("App");
logger.level = config.logLevel; logger.level = config.logLevel;
const isHeadless = process.argv.includes("--headless");
export async function init() { export async function init() {
if (!isHeadless) {
// Create Electron window first // Create Electron window first
logger.info("Creating electron window..."); logger.info("Creating electron window...");
await createElectronWindow().catch((err) => { await createElectronWindow().catch((err) => {
@ -21,6 +24,10 @@ export async function init() {
setupIPC().catch((err) => { setupIPC().catch((err) => {
logger.warn("Failed to setup IPC:", err); logger.warn("Failed to setup IPC:", err);
}); });
} else {
logger.info("Running in headless mode. Skipping window creation.");
}
const socketClient = new SocketClient(); const socketClient = new SocketClient();
// Make socket client globally accessible for IPC handlers // Make socket client globally accessible for IPC handlers
global.socketClient = socketClient; global.socketClient = socketClient;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,155 @@
// printerfilemanager.js - Manages file uploads from FileManager to Moonraker
import axios from "axios";
import FormData from "form-data";
import log4js from "log4js";
import { loadConfig } from "../config.js";
const config = loadConfig();
const logger = log4js.getLogger("Printer File Manager");
logger.level = config.logLevel;
export class PrinterFileManager {
constructor(printerClient) {
this.printerClient = printerClient;
this.socketClient = printerClient.socketClient;
this.uploadingFiles = new Map(); // Track ongoing uploads by fileId
this.progressCallbacks = new Map();
}
/**
* Upload a file from FileManager to Moonraker
* @param {string} fileId - The ID of the file to upload (from FileManager)
* @param {string} [fileName] - Optional name to use for the file on Moonraker (will use file object name if not provided)
* @param {Object} moonrakerConfig - Moonraker configuration object with protocol, host, port, and optional apiKey
* @param {Function} [onProgress] - Optional progress callback (percent) => void
* @returns {Promise<boolean>} - True if upload succeeded, false otherwise
*/
async uploadFile(fileId, file, onProgress = null) {
if (!fileId) {
throw new Error("File ID is required");
}
if (onProgress) {
const progressCallbacks = this.progressCallbacks.get(fileId) || [];
progressCallbacks.push(onProgress);
this.progressCallbacks.set(fileId, progressCallbacks);
}
// Check if upload is already in progress for this file
if (this.uploadingFiles.has(fileId)) {
logger.debug(`File ${fileId} upload already in progress, waiting...`);
try {
return await this.uploadingFiles.get(fileId);
} catch (error) {
// If the upload failed, remove it from the map so it can be retried
this.uploadingFiles.delete(fileId);
throw error;
}
}
if (
!this.printerClient.config ||
!this.printerClient.config.host ||
!this.printerClient.config.port
) {
throw new Error("Printer configuration is required");
}
// Create upload promise and store it
const uploadPromise = this._performUpload(fileId, file)
.then((result) => {
// Remove from uploading map on success
this.uploadingFiles.delete(fileId);
return result;
})
.catch((error) => {
// Remove from uploading map on error
this.uploadingFiles.delete(fileId);
throw error;
});
// Store the promise in the map
this.uploadingFiles.set(fileId, uploadPromise);
return await uploadPromise;
}
/**
* Internal method to perform the actual upload
* @private
*/
async _performUpload(fileId, file, onProgress = null) {
try {
const uploadFileName = `${fileId}.gcode`;
const { protocol, host, port } = this.printerClient.config;
const httpUrl = `${
protocol === "ws" ? "http" : "https"
}://${host}:${port}/server/files/upload`;
logger.info(
`Uploading file ${uploadFileName} to printer ${this.printerClient.id} at ${httpUrl}`
);
// Create FormData with the file buffer
const formData = new FormData();
formData.append("file", file, {
filename: "farmcontrol/" + uploadFileName,
contentType: "text/plain",
});
// Set up headers
const headers = {
...formData.getHeaders(),
};
// Add API key if provided
if (this.printerClient.config.apiKey) {
headers["X-Api-Key"] = this.printerClient.config.apiKey;
}
// Upload to Moonraker
const response = await axios.post(httpUrl, formData, {
headers,
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
logger.debug(
`Uploading file to printer ${this.printerClient.id}: ${uploadFileName} ${percentCompleted}%`
);
if (this.progressCallbacks.has(fileId)) {
const progressCallbacks = this.progressCallbacks.get(fileId);
for (const callback of progressCallbacks) {
callback(percentCompleted);
}
}
}
},
});
// Check response
if (response.data && response.data.action) {
logger.info(
`Successfully uploaded file ${uploadFileName} to printer ${this.printerClient.id}`
);
return true;
} else {
logger.error(
`Failed to upload file ${uploadFileName} to printer ${this.printerClient.id}: Invalid response`
);
return false;
}
} catch (error) {
logger.error(
`Error uploading file ${fileId} to printer ${this.printerClient.id}: ${error.message}`
);
if (error.response) {
logger.error(`Response status: ${error.response.status}`);
logger.error(`Response data: ${JSON.stringify(error.response.data)}`);
}
throw error;
}
}
}

View File

@ -17,27 +17,39 @@ export class PrinterManager {
} }
async reloadPrinters() { async reloadPrinters() {
logger.info("Reloading printers...");
try { try {
this.printers = await this.socketClient.listObjects({ this.printers = await this.socketClient.listObjects({
objectType: "printer", objectType: "printer",
filter: { host: this.socketClient.id }, filter: { host: this.socketClient.id },
populate: [
"currentFilamentStock",
"currentJob",
"currentSubJob",
"queue",
],
}); });
sendIPC("setPrinters", this.printers); sendIPC("setPrinters", this.printers);
var removedPrintersCount = 0;
// Remove printer clients that are no longer in the printers list // Remove printer clients that are no longer in the printers list
const printerIds = this.printers.map((printer) => printer._id); const printerIds = this.printers.map((printer) => printer._id);
for (const [printerId, printerClient] of this.printerClients.entries()) { for (const [printerId, printerClient] of this.printerClients.entries()) {
if (!printerIds.includes(printerId)) { if (!printerIds.includes(printerId)) {
// Close the connection before removing // Close the connection before removing
if (printerClient.socket) { if (printerClient.socket) {
printerClient.shouldReconnect = false;
printerClient.socket.close(); printerClient.socket.close();
} }
this.printerClients.delete(printerId); this.printerClients.delete(printerId);
logger.info(`Removed printer client for printer ID: ${printerId}`); logger.info(`Removed printer client for printer ID: ${printerId}`);
removedPrintersCount++;
} }
} }
var addedPrintersCount = 0;
// Add new printer clients for printers not in the printerClients map // Add new printer clients for printers not in the printerClients map
for (const printer of this.printers) { for (const printer of this.printers) {
const printerId = printer._id; const printerId = printer._id;
@ -46,15 +58,74 @@ export class PrinterManager {
await printerClient.connect(); await printerClient.connect();
this.printerClients.set(printerId, printerClient); this.printerClients.set(printerId, printerClient);
logger.info(`Added printer client for printer ID: ${printerId}`); logger.info(`Added printer client for printer ID: ${printerId}`);
addedPrintersCount++;
} }
} }
logger.debug("Printers added:", addedPrintersCount);
logger.debug("Printers removed:", removedPrintersCount);
} catch (error) { } catch (error) {
logger.error("Failed to update printers:", error); logger.error("Failed to update printers:", error);
this.printers = []; this.printers = [];
} }
} }
async setupPrintersListener() {} async handlePrinterAction(id, action, callback) {
logger.debug("Running printer action...", action);
const printer = this.getPrinterClient(id);
switch (action.type) {
case "setTemperature":
const setTempResult = await printer.setTemperature(action.data);
callback(setTempResult);
return;
case "restartPrinterFirmware":
const restartPrinterFirmwareResult =
await printer.restartPrinterFirmware();
callback(restartPrinterFirmwareResult);
return;
case "restartPrinter":
const restartPrinterResult = await printer.restartPrinter();
callback(restartPrinterResult);
return;
case "restartMoonraker":
const restartMoonrakerResult = await printer.restartMoonraker();
callback(restartMoonrakerResult);
return;
case "deploy":
const deployResult = await printer.deploySubJob(action.data);
callback(deployResult);
return;
case "startQueue":
const startQueueResult = await printer.startQueue();
callback(startQueueResult);
return;
case "pauseJob":
const pauseJobResult = await printer.pauseJob();
callback(pauseJobResult);
return;
case "resumeJob":
const resumeJobResult = await printer.resumeJob();
callback(resumeJobResult);
return;
case "cancelJob":
const cancelJobResult = await printer.cancelJob();
callback(cancelJobResult);
return;
case "unloadFilamentStock":
const unloadFilamentStockResult = await printer.unloadFilamentStock();
callback(unloadFilamentStockResult);
return;
case "loadFilamentStock":
const loadFilamentStockResult = await printer.loadFilamentStock(
action.data.filamentStock
);
callback(loadFilamentStockResult);
return;
}
callback({ error: "Unknown command." });
}
getPrinterClient(printerId) { getPrinterClient(printerId) {
return this.printerClients.get(printerId); return this.printerClients.get(printerId);
@ -64,112 +135,41 @@ export class PrinterManager {
return this.printerClients.values(); return this.printerClients.values();
} }
async updateSubscription(printerId, socketId, mergedSubscription) {
const printerClient = this.printerClients.get(printerId);
if (!printerClient) {
return {
success: false,
error: `Printer with ID ${printerId} not found`,
};
}
printerClient.subscriptions.set(socketId, mergedSubscription);
return await printerClient.updateSubscriptions();
}
// Close all printer connections // Close all printer connections
closeAllConnections() { async closeAllConnections() {
for (const printerClient of this.printerClients.values()) { logger.info(
if (printerClient.socket) { `Closing all printer connections... current count: ${this.printerClients.size}`
printerClient.socket.close(); );
}
}
}
async downloadGCODE(gcodeFileId) { // Take a snapshot so any mutations during disconnects don't affect iteration
logger.info(`Downloading G-code file ${gcodeFileId}`); const clients = Array.from(this.printerClients.values());
for (const printerClient of clients) {
try { try {
// Download the G-code file with authentication // Ensure we never auto-reconnect after a manual close-all
const url = `http://localhost:8080/gcodefiles/${gcodeFileId}/content/`; printerClient.shouldReconnect = false;
const response = await fetch(url, { await printerClient.disconnect();
headers: { logger.info(
Authorization: `Bearer ${ `Disconnected printer client ${printerClient?.id || "unknown"}`
this.socketManager.socketClientConnections.values().next().value );
.socket.handshake.auth.token } catch (error) {
}`, logger.error(
}, `Failed to disconnect printer client ${
}); printerClient?.id || "unknown"
if (!response.ok) { }:`,
throw new Error( error
`Failed to download G-code file: ${response.statusText}`
); );
} }
const gcodeContent = await response.blob();
logger.info(`G-code file ${gcodeFileId} downloaded!`);
return gcodeContent;
} catch (error) {
logger.error("Error in deployGcodeToAllPrinters:", error);
return {
success: false,
error: error.message,
};
}
} }
async deployPrintJob(printJobId) { console.log("Printer clients:", this.printerClients);
logger.info(`Deploying print job ${printJobId}`);
const printJob = await jobModel
.findById(printJobId)
.populate("printers")
.populate("subJobs");
if (!printJob) {
throw new Error("Print job not found");
}
if (!printJob.gcodeFile) { // Clear local references so no stale clients remain
throw new Error("No G-code file associated with this print job"); this.printerClients.clear();
} this.printers = [];
const gcodeFileId = printJob.gcodeFile.toString(); logger.info(
const fileName = `${printJob.id}.gcode`; `All printer connections closed. Remaining clients: ${this.printerClients.size}`
);
const gcodeFile = await this.downloadGCODE(gcodeFileId);
for (const printer of printJob.printers) {
const printerClient = this.getPrinterClient(printer.id);
if (!printerClient) {
throw new Error(`Printer with ID ${printer.id} not found`);
return false;
}
await printerClient.uploadGcodeFile(gcodeFile, fileName);
await printerClient.deploySubJobs(printJob.id);
}
printJob.state = { type: "queued" };
printJob.updatedAt = new Date();
await printJob.save();
this.socketManager.broadcast("notify_job_update", {
id: printJob.id,
state: { type: "queued" },
});
return true;
}
async cancelSubJob(subJobId) {
logger.info(`Canceling sub job ${subJobId}`);
const subJob = await subJobModel.findById(subJobId);
if (!subJob) {
throw new Error("Sub job not found");
}
const printerClient = this.getPrinterClient(subJob.printer.toString());
if (!printerClient) {
throw new Error(`Printer with ID ${printer.id} not found`);
return false;
}
await printerClient.cancelSubJob(subJob.subJobId);
return true;
} }
} }

View File

@ -7,6 +7,8 @@ import { askOtp, getDeviceInfo, notPrompting } from "../utils.js";
import { sendIPC } from "../electron/ipc.js"; import { sendIPC } from "../electron/ipc.js";
import { PrinterManager } from "../printer/printermanager.js"; import { PrinterManager } from "../printer/printermanager.js";
import { HostManager } from "../host/hostmanager.js"; import { HostManager } from "../host/hostmanager.js";
import { FileManager } from "../files/filemanager.js";
import { DocumentPrinterManager } from "../documentprinter/documentprintermanager.js";
const config = loadConfig(); const config = loadConfig();
@ -22,9 +24,10 @@ export class SocketClient {
this.host = null; this.host = null;
this.id = null; this.id = null;
this.reconnectTimeout = null; this.reconnectTimeout = null;
this.reloadPrintersInterval = null;
this.hostManager = new HostManager(this); this.hostManager = new HostManager(this);
this.fileManager = new FileManager(this);
this.printerManager = new PrinterManager(this); this.printerManager = new PrinterManager(this);
this.documentPrinterManager = new DocumentPrinterManager(this);
this.scanner = new WebSocketScanner({ maxThreads: 50 }); this.scanner = new WebSocketScanner({ maxThreads: 50 });
this.readLine = null; this.readLine = null;
sendIPC("setOnline", false); sendIPC("setOnline", false);
@ -37,6 +40,7 @@ export class SocketClient {
this.socket.on("connect_error", this.handleError.bind(this)); this.socket.on("connect_error", this.handleError.bind(this));
this.socket.on("objectUpdate", this.handleObjectUpdate.bind(this)); this.socket.on("objectUpdate", this.handleObjectUpdate.bind(this));
this.socket.on("objectAction", this.handleObjectAction.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)); this.socket.on("disconnect", this.handleDisconnect.bind(this));
} }
@ -54,19 +58,40 @@ export class SocketClient {
}, 3000); }, 3000);
} }
scheduleReloadPrinters() { subscribeToObjectEvents() {
// Clear any pending reconnect timeout this.subscribeToObjectEvent({
if (this.reloadPrintersInterval) { objectType: "host",
clearTimeout(this.reloadPrintersInterval); _id: this.id,
this.reloadPrintersInterval = null; eventType: "childUpdate",
});
this.subscribeToObjectEvent({
objectType: "host",
_id: this.id,
eventType: "childDelete",
});
this.subscribeToObjectEvent({
objectType: "host",
_id: this.id,
eventType: "childNew",
});
} }
logger.info("Reloading printers..."); unsubscribeFromObjectEvents() {
this.printerManager.reloadPrinters(); this.unsubscribeFromObjectEvent({
this.reloadPrintersInterval = setInterval(() => { objectType: "host",
logger.info("Reloading printers..."); _id: this.id,
this.printerManager.reloadPrinters(); eventType: "childUpdate",
}, 30000); });
this.unsubscribeFromObjectEvent({
objectType: "host",
_id: this.id,
eventType: "childDelete",
});
this.unsubscribeFromObjectEvent({
objectType: "host",
_id: this.id,
eventType: "childNew",
});
} }
connect() { connect() {
@ -74,6 +99,7 @@ export class SocketClient {
logger.info(`Connecting to Socket.IO server: ${config.url}`); logger.info(`Connecting to Socket.IO server: ${config.url}`);
this.socket = io(config.url, { this.socket = io(config.url, {
auth: { type: "host" }, auth: { type: "host" },
reconnection: false,
timeout: 3000, // 3 second timeout timeout: 3000, // 3 second timeout
}); });
this.loading = true; this.loading = true;
@ -89,6 +115,7 @@ export class SocketClient {
} }
disconnect() { disconnect() {
this.unsubscribeFromObjectEvents();
this.socket.disconnect(); this.socket.disconnect();
} }
@ -129,7 +156,10 @@ export class SocketClient {
config.host = { id: this.id, authCode: this.host.authCode }; config.host = { id: this.id, authCode: this.host.authCode };
saveConfig(config); saveConfig(config);
this.sendDeviceInfo(); this.sendDeviceInfo();
this.scheduleReloadPrinters(); this.subscribeToObjectEvents();
this.documentPrinterManager.reloadDocumentPrinters();
this.printerManager.reloadPrinters();
await this.fileManager.updateFiles();
} }
); );
} }
@ -146,18 +176,6 @@ export class SocketClient {
callback({ success: true }); callback({ success: true });
} }
async handlePrinterAction(id, action, callback) {
console.log("RUNNING PRINTER ACTION");
const printer = this.printerManager.getPrinterClient(id);
switch (action.type) {
case "setTemperature":
const result = await printer.setTemperature(action.data);
callback(result);
return;
}
callback({ success: true });
}
handleObjectAction(data, callback) { handleObjectAction(data, callback) {
logger.debug("Running object action...", data); logger.debug("Running object action...", data);
const id = data._id; const id = data._id;
@ -169,15 +187,35 @@ export class SocketClient {
} }
if (objectType == "printer") { if (objectType == "printer") {
this.handlePrinterAction(id, action, callback); this.printerManager.handlePrinterAction(id, action, callback);
}
if (objectType == "documentPrinter") {
this.documentPrinterManager.handleDocumentPrinterAction(
id,
action,
callback
);
} }
} }
handleObjectUpdate(data, callback) { handleObjectUpdate(data, callback) {
logger.debug("Got object update", data); logger.debug(
"Got object update for type:",
data.objectType,
" id:",
data._id
);
if (data.objectType == "host") { if (data.objectType == "host") {
this.handleHostAction(action, callback); this.handleHostAction(action, callback);
} }
if (data.objectType == "documentPrinter") {
this.documentPrinterManager.handleDocumentPrinterUpdate(
data._id,
data,
callback
);
}
} }
handleConnect() { handleConnect() {
@ -237,7 +275,7 @@ export class SocketClient {
project, project,
cached, cached,
}) { }) {
logger.debug("Listing objects...", { logger.trace("Listing objects...", {
objectType, objectType,
populate, populate,
filter, filter,
@ -279,8 +317,35 @@ export class SocketClient {
}); });
} }
async editObject({ objectType, _id, populate, updateData }) { async newObject({ objectType, newData }) {
logger.debug("Editing object...", { 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 }) {
logger.trace("Editing object...", {
objectType, objectType,
_id, _id,
populate, populate,
@ -293,6 +358,7 @@ export class SocketClient {
_id, _id,
populate, populate,
updateData, updateData,
auditLog,
}, },
(result) => { (result) => {
if (result && result.error) { if (result && result.error) {
@ -359,6 +425,21 @@ export class SocketClient {
}); });
} }
async renderTemplatePDF(templateData) {
logger.debug("Rendering template PDF...", templateData);
return new Promise((resolve, reject) => {
this.socket.emit("renderTemplatePDF", templateData, (result) => {
if (result && result.error) {
logger.error("Failed to render template PDF:", result.error);
reject(new Error(result.error));
} else {
logger.trace("Template PDF rendered successfully.");
resolve(result);
}
});
});
}
async subscribeToObjectActions({ objectType, _id }) { async subscribeToObjectActions({ objectType, _id }) {
logger.debug("Suscribing to object actions...", { logger.debug("Suscribing to object actions...", {
objectType, objectType,
@ -369,6 +450,81 @@ export class SocketClient {
_id, _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,
});
}
async subscribeToObjectEvent({ objectType, _id, eventType }) {
logger.debug("Suscribing to object event...", {
objectType,
_id,
eventType,
});
this.socket.emit("subscribeToObjectEvent", {
objectType,
_id,
eventType,
});
}
async unsubscribeFromObjectEvent({ objectType, _id, eventType }) {
logger.debug("Unsuscribing from object event...", {
objectType,
_id,
eventType,
});
this.socket.emit("unsubscribeFromObjectEvent", {
objectType,
_id,
eventType,
});
}
async handleHostChildUpdate(data) {
if (data.parentType == "printer") {
this.printerManager.reloadPrinters();
} else if (data.parentType == "documentPrinter") {
this.documentPrinterManager.reloadDocumentPrinters();
}
}
async handleObjectEvent({ objectType, _id, event }) {
logger.debug("Received object event...", {
objectType,
_id,
event,
});
const data = event.data;
if (
(event.type == "childUpdate" ||
event.type == "childDelete" ||
event.type == "childNew") &&
objectType == "host" &&
_id == this.id
) {
await this.handleHostChildUpdate(data);
}
}
//-------------------------------------- RE-WRITE ENDS HERE --------------------------------------- //-------------------------------------- RE-WRITE ENDS HERE ---------------------------------------
async handleScanNetworkStart(data, callback) { async handleScanNetworkStart(data, callback) {
@ -432,138 +588,6 @@ export class SocketClient {
} }
} }
async handlePrinterObjectsSubscribe(data, callback) {
logger.debug("Received printer.objects.subscribe event:", data);
try {
if (data && data.printerId) {
const printerId = data.printerId;
// Get existing subscription or create new one
const existingSubscription =
this.activeSubscriptions.get(printerId) || {};
// Merge the new subscription data with existing data
const mergedSubscription = {
...existingSubscription,
...data.objects,
};
this.activeSubscriptions.set(printerId, mergedSubscription);
logger.trace("Merged subscription:", mergedSubscription);
const result = await this.printerManager.updateSubscription(
printerId,
this.socket.id,
mergedSubscription
);
if (callback) {
callback(result);
}
} else {
logger.error("Missing Printer ID in subscription request");
if (callback) {
callback({ error: "Missing Printer ID" });
}
}
} catch (e) {
logger.error("Error processing subscription request:", e);
if (callback) {
callback({ error: e.message });
}
}
}
async handlePrinterObjectsUnsubscribe(data, callback) {
logger.debug("Received printer.objects.unsubscribe event:", data);
try {
if (data && data.printerId) {
const printerId = data.printerId;
const existingSubscription = this.activeSubscriptions.get(printerId);
if (existingSubscription) {
// Create a new objects object without the unsubscribed objects
const remainingObjects = { ...existingSubscription };
if (data.objects) {
for (const key of Object.keys(data.objects)) {
delete remainingObjects[key];
}
}
console.log("remainingObjects", remainingObjects);
console.log("existingSubscription", existingSubscription);
console.log("unsubscribe", data.objects);
// If there are no remaining objects, remove the entire subscription
if (Object.keys(remainingObjects).length === 0) {
this.activeSubscriptions.delete(printerId);
logger.warn("Removing entire subscription");
// Send subscribe command with updated subscription
const result = await this.printerManager.updateSubscription(
printerId,
this.socket.id,
{}
);
if (callback) {
callback(result);
}
} else {
this.activeSubscriptions.set(printerId, remainingObjects);
logger.warn(remainingObjects);
// Send subscribe command with updated subscription
const result = await this.printerManager.updateSubscription(
printerId,
this.socket.id,
remainingObjects
);
if (callback) {
callback(result);
}
}
} else {
logger.warn("No existing subscription found for printer:", printerId);
if (callback) {
callback({ success: true, message: "No subscription found" });
}
}
} else {
logger.error("Missing Printer ID in unsubscribe request");
if (callback) {
callback({ error: "Missing Printer ID" });
}
}
} catch (e) {
logger.error("Error processing unsubscribe request:", e);
if (callback) {
callback({ error: e.message });
}
}
}
async handleGcodeScript(data, callback) {
logger.debug("Received printer.gcode.script event:", data);
try {
const result = await this.printerManager.processPrinterCommand({
method: "printer.gcode.script",
params: data,
});
if (callback) {
callback(result);
}
} catch (e) {
logger.error("Error processing gcode script request:", e);
if (callback) {
callback({ error: e.message });
}
}
}
async handlePrinterObjectsQuery(data, callback) { async handlePrinterObjectsQuery(data, callback) {
logger.debug("Received printer.objects.query event:", data); logger.debug("Received printer.objects.query event:", data);
try { try {
@ -602,197 +626,6 @@ export class SocketClient {
} }
} }
async handleFirmwareRestart(data, callback) {
logger.debug("Received printer.firmware_restart event:", data);
try {
const result = await this.printerManager.processPrinterCommand({
method: "printer.firmware_restart",
params: data,
});
if (callback) {
callback(result);
}
} catch (e) {
logger.error("Error processing firmware restart request:", e);
if (callback) {
callback({ error: e.message });
}
}
}
async handlePrinterRestart(data, callback) {
logger.debug("Received printer.restart event:", data);
try {
const result = await this.printerManager.processPrinterCommand({
method: "printer.restart",
params: data,
});
if (callback) {
callback(result);
}
} catch (e) {
logger.error("Error processing printer restart request:", e);
if (callback) {
callback({ error: e.message });
}
}
}
async handleJobQueueStatus(data, callback) {
logger.debug("Received server.job_queue.status event:", data);
try {
const result = await this.printerManager.processPrinterCommand({
method: "server.job_queue.status",
params: data,
});
if (callback) {
callback(result);
}
} catch (e) {
logger.error("Error processing job queue status request:", e);
if (callback) {
callback({ error: e.message });
}
}
}
async handleJobQueueDeploy(data, callback) {
logger.debug("Received server.job_queue.deploy event:", data);
try {
if (!data || !data.jobId) {
throw new Error("Missing required job ID");
}
// Deploy the job to all printers
const result = await this.printerManager.deployJob(data.jobId);
if (callback) {
callback(result);
}
} catch (e) {
logger.error("Error processing job queue deploy request:", e);
if (callback) {
callback({ error: e.message });
}
}
}
async handlePrintResume(data, callback) {
logger.debug("Received printer.print.resume event:", data);
try {
const result = await this.printerManager.processPrinterCommand({
method: "printer.print.resume",
params: data,
});
if (callback) {
callback(result);
}
} catch (e) {
logger.error("Error processing print resume request:", e);
if (callback) {
callback({ error: e.message });
}
}
}
async handleJobQueueCancel(data, callback) {
logger.debug("Received server.job_queue.cancel event:", data);
try {
if (!data || !data.subJobId) {
throw new Error("Missing required sub job ID");
}
const result = await this.printerManager.cancelSubJob(data.subJobId);
if (callback) {
callback(result);
}
} catch (e) {
logger.error("Error processing job queue delete job request:", e);
if (callback) {
callback({ error: e.message });
}
}
}
async handlePrintCancel(data, callback) {
logger.debug("Received printer.print.cancel event:", data);
try {
const result = await this.printerManager.processPrinterCommand({
method: "printer.print.cancel",
params: data,
});
if (callback) {
callback(result);
}
} catch (e) {
logger.error("Error processing print cancel request:", e);
if (callback) {
callback({ error: e.message });
}
}
}
async handlePrintPause(data, callback) {
logger.debug("Received printer.print.pause event:", data);
try {
const result = await this.printerManager.processPrinterCommand({
method: "printer.print.pause",
params: data,
});
if (callback) {
callback(result);
}
} catch (e) {
logger.error("Error processing print pause request:", e);
if (callback) {
callback({ error: e.message });
}
}
}
async handleJobQueuePause(data, callback) {
logger.debug("Received server.job_queue.pause event:", data);
try {
const result = await this.printerManager.processPrinterCommand({
method: "server.job_queue.pause",
params: data,
});
if (callback) {
callback(result);
}
} catch (e) {
logger.error("Error processing job queue pause request:", e);
if (callback) {
callback({ error: e.message });
}
}
}
async handleJobQueueStart(data, callback) {
logger.debug("Received server.job_queue.start event:", data);
try {
const result = await this.printerManager.processPrinterCommand({
method: "server.job_queue.start",
params: data,
});
if (callback) {
callback(result);
}
} catch (e) {
logger.error("Error processing job queue start request:", e);
if (callback) {
callback({ error: e.message });
}
}
}
async handleFilamentStockLoad(data, callback) { async handleFilamentStockLoad(data, callback) {
logger.debug("Received printer.filamentstock.load event:", data); logger.debug("Received printer.filamentstock.load event:", data);
try { try {
@ -827,17 +660,14 @@ export class SocketClient {
} }
} }
handleDisconnect() { async handleDisconnect() {
logger.info("Disconnected from FarmControl Api."); logger.info("Disconnected from FarmControl Api.");
this.printerManager.closeAllConnections(); await this.printerManager.closeAllConnections();
await this.documentPrinterManager.closeAllConnections();
this.connected = false; this.connected = false;
sendIPC("setConnected", false); sendIPC("setConnected", false);
this.authenticated = false; this.authenticated = false;
sendIPC("setAuthenticated", false); sendIPC("setAuthenticated", false);
if (this.reloadPrintersInterval) {
clearTimeout(this.reloadPrintersInterval);
this.reloadPrintersInterval = null;
}
notPrompting(); notPrompting();
this.scheduleReconnect(); this.scheduleReconnect();
} }

View File

@ -1,5 +1,7 @@
import readline from "node:readline"; import readline from "node:readline";
import os from "os"; import os from "os";
import { pdf } from "pdf-to-img";
import sharp from "sharp";
let isPrompting = false; // prevent multiple prompts at the same time let isPrompting = false; // prevent multiple prompts at the same time
@ -54,3 +56,71 @@ export function getDeviceInfo() {
}, },
}; };
} }
/**
* Converts a PDF buffer or file path to an array of image buffers
* @param {Buffer|string} pdfInput - PDF buffer or file path
* @param {Object} options - Conversion options (optional)
* @param {number} options.scale - Scale factor for image resolution (default: 2)
* @param {number} options.width - Target width in pixels (will calculate scale if provided)
* @param {number} options.height - Target height in pixels (optional)
* @param {number[]} options.page_numbers - Array of page numbers to convert (optional, converts all pages by default)
* @returns {Promise<Buffer[]>} Array of image buffers, one for each page
*/
export async function convertPDFToImage(pdfInput, options = {}) {
try {
// pdf-to-img uses scale instead of width/height directly
// Default scale of 2 provides good quality for thermal printers
let scale = options.scale || 2;
const pdfOptions = {
scale,
...options,
};
const document = await pdf(pdfInput, pdfOptions);
const outputImages = [];
// If specific page numbers are requested, use getPage() for each
if (
options.page_numbers &&
Array.isArray(options.page_numbers) &&
options.page_numbers.length > 0
) {
for (const pageNum of options.page_numbers) {
// page numbers are 1-indexed in the API
let image = await document.getPage(pageNum);
// Resize with Sharp if width or height are provided
if (options.width || options.height) {
const resizeOptions = {};
if (options.width) resizeOptions.width = options.width;
if (options.height) resizeOptions.height = options.height;
image = await sharp(image).resize(resizeOptions).toBuffer();
}
outputImages.push(image);
}
} else {
// Convert all pages: async iterable to array
for await (const image of document) {
let processedImage = image;
// Resize with Sharp if width or height are provided
if (options.width || options.height) {
const resizeOptions = {};
if (options.width) resizeOptions.width = options.width;
if (options.height) resizeOptions.height = options.height;
processedImage = await sharp(image).resize(resizeOptions).toBuffer();
}
outputImages.push(processedImage);
}
}
return outputImages; // Array of image buffers
} catch (error) {
console.error("Error converting PDF to image:", error);
throw error;
}
}

7597
yarn.lock Normal file

File diff suppressed because it is too large Load Diff