diff --git a/src/documentprinter/documentprinterclient.js b/src/documentprinter/documentprinterclient.js index a909df1..20e38af 100644 --- a/src/documentprinter/documentprinterclient.js +++ b/src/documentprinter/documentprinterclient.js @@ -11,6 +11,18 @@ const config = loadConfig(); const logger = log4js.getLogger("Document Printer Client"); logger.level = config.logLevel; +function getDocumentSizeId(documentSize) { + if (!documentSize) { + return null; + } + + if (typeof documentSize === "string") { + return documentSize; + } + + return documentSize._id?.toString?.() ?? null; +} + export class DocumentPrinterClient { constructor( documentPrinter = { @@ -29,16 +41,18 @@ export class DocumentPrinterClient { this.socketClient = documentPrinterManager.socketClient; this.interface = documentPrinter.connection.interface || "cups"; // cups, receipt, or os this.state = { type: this.active == true ? "offline" : "inactive" }; + this.paperState = documentPrinter.paperState || { type: "unknown" }; this.isOnline = documentPrinter.online || false; this.shouldReconnect = true; this.isConnecting = false; this.reconnectTimeout = null; this.isProcessingQueue = false; - this.eventUpdateInterval = null; + this.subscribedDocumentSizeId = null; this.initializeInterface(); this.registerEventHandlers(); this.subscribeToActions(); this.subscribeToObjectUpdates(); + this.syncCurrentDocumentSizeSubscription(); } initializeInterface() { @@ -68,6 +82,12 @@ export class DocumentPrinterClient { logger.debug(`Updating document printer ${this.id} with data...`); sendIPC("setDocumentPrinter", { _id: this.id, ...data }); + const previousDocumentSizeId = getDocumentSizeId( + this.documentPrinter?.currentDocumentSize, + ); + const previousRotateOrientation = + this.documentPrinter?.rotateOrientation == true; + // Check for connection changes before updating if (data?.connection) { @@ -83,9 +103,20 @@ export class DocumentPrinterClient { newConnection.interface != null && newConnection.interface !== oldConnection.interface; const portChanged = newConnection?.port !== oldConnection?.port; + const usernameChanged = + newConnection.username != null && + newConnection.username !== oldConnection.username; + const passwordChanged = + newConnection.password != null && + newConnection.password !== oldConnection.password; const connectionChanged = - hostChanged || interfaceChanged || portChanged || protocolChanged; + hostChanged || + interfaceChanged || + portChanged || + protocolChanged || + usernameChanged || + passwordChanged; logger.debug(`Connection changed: ${connectionChanged}`); logger.debug(`Host changed: ${hostChanged}`); @@ -107,11 +138,34 @@ export class DocumentPrinterClient { await this.printerInterface.disconnect(); this.initializeInterface(); await this.reconnect(); + } else if (data?.connection) { + this.connection = { ...oldConnection, ...newConnection }; } } this.documentPrinter = { ...this.documentPrinter, ...data }; + const nextDocumentSizeId = Object.hasOwn(data || {}, "currentDocumentSize") + ? getDocumentSizeId(data.currentDocumentSize) + : previousDocumentSizeId; + const nextRotateOrientation = Object.hasOwn(data || {}, "rotateOrientation") + ? data.rotateOrientation == true + : previousRotateOrientation; + const documentSizeChanged = nextDocumentSizeId !== previousDocumentSizeId; + const rotateOrientationChanged = + nextRotateOrientation !== previousRotateOrientation; + + if (documentSizeChanged) { + this.syncCurrentDocumentSizeSubscription(); + } + + if ( + (documentSizeChanged || rotateOrientationChanged) && + this.printerInterface?.handleCurrentDocumentSizeChange + ) { + await this.applyCurrentDocumentSizeDefaults(); + } + if (Object.hasOwn(data || {}, "active") && data.active != this.active) { if (data.active == true) { await this.setActive(); @@ -121,6 +175,80 @@ export class DocumentPrinterClient { } } + syncCurrentDocumentSizeSubscription() { + const nextDocumentSizeId = getDocumentSizeId( + this.documentPrinter?.currentDocumentSize, + ); + + if (this.subscribedDocumentSizeId === nextDocumentSizeId) { + return; + } + + if (this.subscribedDocumentSizeId) { + this.socketClient.unsubscribeFromObjectUpdates({ + objectType: "documentSize", + _id: this.subscribedDocumentSizeId, + }); + } + + this.subscribedDocumentSizeId = nextDocumentSizeId; + + if (this.subscribedDocumentSizeId) { + this.socketClient.subscribeToObjectUpdates({ + objectType: "documentSize", + _id: this.subscribedDocumentSizeId, + }); + } + } + + async applyCurrentDocumentSizeDefaults() { + if (!this.printerInterface?.handleCurrentDocumentSizeChange) { + return; + } + + const mediaResult = + await this.printerInterface.handleCurrentDocumentSizeChange( + this.documentPrinter.currentDocumentSize, + ); + if (mediaResult?.error) { + logger.warn( + `Failed to apply current document size for ${this.documentPrinter.name}: ${mediaResult.error}`, + ); + } + } + + async handleCurrentDocumentSizeUpdate(documentSize) { + const documentSizeId = getDocumentSizeId(documentSize); + const currentDocumentSizeId = getDocumentSizeId( + this.documentPrinter?.currentDocumentSize, + ); + + if ( + !documentSizeId || + !currentDocumentSizeId || + documentSizeId !== currentDocumentSizeId + ) { + return; + } + + const previousDocumentSize = + typeof this.documentPrinter.currentDocumentSize === "object" && + this.documentPrinter.currentDocumentSize + ? this.documentPrinter.currentDocumentSize + : { _id: currentDocumentSizeId }; + + this.documentPrinter = { + ...this.documentPrinter, + currentDocumentSize: { + ...previousDocumentSize, + ...documentSize, + _id: documentSizeId, + }, + }; + + await this.applyCurrentDocumentSizeDefaults(); + } + registerEventHandlers() { // Register event handlers for document printer notifications // This can be extended as needed @@ -140,6 +268,18 @@ export class DocumentPrinterClient { }); } + unsubscribeFromCurrentDocumentSizeUpdates() { + if (!this.subscribedDocumentSizeId) { + return; + } + + this.socketClient.unsubscribeFromObjectUpdates({ + objectType: "documentSize", + _id: this.subscribedDocumentSizeId, + }); + this.subscribedDocumentSizeId = null; + } + statesEqual(a, b) { if (!a && !b) { return true; @@ -152,6 +292,44 @@ export class DocumentPrinterClient { ); } + paperStatesEqual(a, b) { + if (!a && !b) { + return true; + } + if (!a || !b) { + return false; + } + return a.type === b.type && a.message === b.message; + } + + shouldPreserveStateFromStatusPolling() { + return ["printing", "initializing", "connecting"].includes(this.state?.type); + } + + applyPrinterStatus(status) { + if (!status || status.error) { + return false; + } + + let changed = false; + + if (status.state && !this.shouldPreserveStateFromStatusPolling()) { + if (!this.statesEqual(this.state, status.state)) { + this.state = status.state; + changed = true; + } + } + + if (status.paperState) { + if (!this.paperStatesEqual(this.paperState, status.paperState)) { + this.paperState = status.paperState; + changed = true; + } + } + + return changed; + } + scheduleReconnect(delay = 30000) { if (!this.shouldReconnect || this.active === false) { return; @@ -187,9 +365,7 @@ export class DocumentPrinterClient { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; - // Always stop the event update interval when connecting - clearInterval(this.eventUpdateInterval); - this.eventUpdateInterval = null; + await this.stopStatusMonitoring(); this.state = { type: "connecting", message: null }; this.isOnline = false; await this.updateDocumentPrinterState(); @@ -241,6 +417,8 @@ export class DocumentPrinterClient { return false; } + this.syncCurrentDocumentSizeSubscription(); + clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; @@ -295,10 +473,7 @@ export class DocumentPrinterClient { } this.state = { type: "standby", message: null }; await this.updateDocumentPrinterState(); - this.eventUpdateInterval = setInterval( - this.handleEventUpdate.bind(this), - 3000, - ); + await this.startStatusMonitoring(); await stopWaiting(); return true; } @@ -309,16 +484,30 @@ export class DocumentPrinterClient { } } - async handleEventUpdate() { - if (this.printerInterface && this.printerInterface.retrieveStatus) { - try { - //await this.printerInterface.retrieveStatus(); - } catch (error) { + async startStatusMonitoring() { + if (this.printerInterface?.startStatusMonitoring) { + await this.printerInterface.startStatusMonitoring(); + } + } + + async stopStatusMonitoring() { + if (this.printerInterface?.stopStatusMonitoring) { + await this.printerInterface.stopStatusMonitoring(); + } + } + + async handlePrinterStatusUpdate(status) { + if (!status || status.error) { + if (status?.error) { logger.error( - `Error retrieving status for document printer ${this.documentPrinter.name}:`, - error, + `Error retrieving status for document printer ${this.documentPrinter.name}: ${status.error}`, ); } + return; + } + + if (this.applyPrinterStatus(status)) { + await this.updateDocumentPrinterState(); } } @@ -326,6 +515,7 @@ export class DocumentPrinterClient { const updateData = { online: this.isOnline, state: this.state, + paperState: this.paperState, connectedAt: this.connectedAt ?? null, }; @@ -337,6 +527,10 @@ export class DocumentPrinterClient { if ( this.documentPrinter?.online === updateData.online && this.statesEqual(this.documentPrinter?.state, updateData.state) && + this.paperStatesEqual( + this.documentPrinter?.paperState, + updateData.paperState, + ) && connectedAtUnchanged ) { return; @@ -590,7 +784,10 @@ export class DocumentPrinterClient { ); await this.updateJobState(jobId, { type: "printing" }); - await this.printerInterface.print(jobId, quantity); + const result = await this.printerInterface.print(jobId, quantity); + if (result?.success === false) { + throw new Error(result.error || "Print failed"); + } logger.info( `Successfully printed job ${jobId} (${quantity} ${quantity === 1 ? "copy" : "copies"}) for ${this.documentPrinter.name}`, ); @@ -630,14 +827,14 @@ export class DocumentPrinterClient { this.shouldReconnect = false; clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; - // Always stop the event update interval when disconnecting - clearInterval(this.eventUpdateInterval); - this.eventUpdateInterval = null; + this.unsubscribeFromCurrentDocumentSizeUpdates(); + await this.stopStatusMonitoring(); if (this.printerInterface && this.printerInterface.disconnect) { await this.printerInterface.disconnect(); } this.isOnline = false; this.state = { type: this.active == false ? "inactive" : "offline" }; + this.paperState = { type: "unknown" }; this.isProcessingQueue = false; this.queue = []; // Clear queue on disconnect this.jobQuantities.clear(); @@ -660,6 +857,7 @@ export class DocumentPrinterClient { this.documentPrinter.active = true; this.shouldReconnect = true; this.isOnline = false; + this.syncCurrentDocumentSizeSubscription(); await this.reconnect(); } } diff --git a/src/documentprinter/documentprintermanager.js b/src/documentprinter/documentprintermanager.js index 9418c62..e21245b 100644 --- a/src/documentprinter/documentprintermanager.js +++ b/src/documentprinter/documentprintermanager.js @@ -22,6 +22,7 @@ export class DocumentPrinterManager { this.documentPrinters = await this.socketClient.listObjects({ objectType: "documentPrinter", filter: { host: this.socketClient.id }, + populate: ["currentDocumentSize"], }); sendIPC("setDocumentPrinters", this.documentPrinters); @@ -93,6 +94,21 @@ export class DocumentPrinterManager { } } + async handleDocumentSizeUpdate(id, data) { + logger.debug("Handling document size update for id:", id); + const documentSizeId = id?.toString?.() ?? `${id || ""}`; + if (!documentSizeId) { + return; + } + + for (const documentPrinterClient of this.documentPrinterClients.values()) { + await documentPrinterClient.handleCurrentDocumentSizeUpdate({ + _id: documentSizeId, + ...(data.object || data), + }); + } + } + getDocumentPrinterClient(documentPrinterId) { return this.documentPrinterClients.get(documentPrinterId); } diff --git a/src/documentprinter/interfaces/cupsinterface.js b/src/documentprinter/interfaces/cupsinterface.js index 8d4bf56..b10b019 100644 --- a/src/documentprinter/interfaces/cupsinterface.js +++ b/src/documentprinter/interfaces/cupsinterface.js @@ -3,6 +3,123 @@ import log4js from "log4js"; import { loadConfig } from "../../config.js"; import ipp from "ipp"; import { startWaiting, stopWaiting } from "../../spinner.js"; +import { + buildStatusError, + mapCupsJobState, + mapCupsPrinterStatus, + printerStatusesEqual, +} from "../printerstatus.js"; +import { + buildCancelSubscriptionRequest, + buildCreatePrinterSubscriptionRequest, + buildCupsPpdUrl, + buildCupsServerUrl, + buildGetNotificationsRequest, + buildHttpRequestUrl, + buildIppPrinterUri, + extractSubscriptionId, + fetchCupsResource, + isSuccessfulIppStatus, + sendRawIppRequest, +} from "../rawippnotifications.js"; + +const NOTIFICATION_LEASE_DURATION = 86400; +const NOTIFICATION_RETRY_DELAY_MS = 5000; +const STATUS_POLL_INTERVAL_MS = 1000; +const IPP_JOB_POLL_INTERVAL_MS = 1000; +const IPP_JOB_COMPLETION_TIMEOUT_MS = 300000; +const CUPS_ADD_MODIFY_PRINTER = 0x4003; + +// CUPS private operation used to update queue Defaults (media, sides, etc.) +const cupsOperations = ipp.operations || ipp.enums?.["operations-supported"]; +if (cupsOperations && cupsOperations["CUPS-Add-Modify-Printer"] == null) { + cupsOperations["CUPS-Add-Modify-Printer"] = CUPS_ADD_MODIFY_PRINTER; + cupsOperations[CUPS_ADD_MODIFY_PRINTER] = "CUPS-Add-Modify-Printer"; +} + +function getDocumentSizeId(documentSize) { + if (!documentSize) { + return null; + } + + if (typeof documentSize === "string") { + return documentSize; + } + + return documentSize._id?.toString?.() ?? null; +} + +function resolveDocumentSizeDimensions( + documentSize, + rotateOrientation = false, +) { + let width = Number(documentSize?.width); + let height = Number(documentSize?.height); + + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + return null; + } + + if (rotateOrientation) { + const swappedWidth = height; + height = width; + width = swappedWidth; + } + + return { width, height }; +} + +// PPD custom page sizes use Custom.WIDTHxHEIGHTmm (what CUPS stores in *DefaultPageSize). +function buildCupsPageSizeName(documentSize, rotateOrientation = false) { + const dimensions = resolveDocumentSizeDimensions( + documentSize, + rotateOrientation, + ); + if (!dimensions) { + return null; + } + + return `Custom.${dimensions.width}x${dimensions.height}mm`; +} + +function buildCupsPwgMediaName(pageSizeName) { + const match = /^Custom\.(.+)$/i.exec(pageSizeName || ""); + if (!match) { + return pageSizeName; + } + + const sizeLabel = match[1]; + return `custom_${sizeLabel}_${sizeLabel}`; +} + +const PPD_DEFAULT_SIZE_KEYS = [ + "PageSize", + "PageRegion", + "ImageableArea", + "PaperDimension", +]; + +function applyCupsPageSizeDefaults(ppdText, pageSizeName) { + let updated = ppdText; + for (const key of PPD_DEFAULT_SIZE_KEYS) { + const pattern = new RegExp(`^\\*Default${key}:.*$`, "m"); + if (!pattern.test(updated)) { + continue; + } + updated = updated.replace(pattern, `*Default${key}: ${pageSizeName}`); + } + return updated; +} + +function getPpdDefaultPageSize(ppdText) { + const match = /^\*DefaultPageSize:\s*(.+)\s*$/m.exec(ppdText || ""); + return match?.[1]?.trim() || null; +} const config = loadConfig(); const logger = log4js.getLogger("CUPS Printer Interface"); @@ -19,6 +136,200 @@ export default class CupsInterface { this.isConnected = false; this.cupsPrinter = null; this.pdfs = new Map(); + this.statusMonitoringActive = false; + this.statusMonitoringMode = null; + this.statusPollingInterval = null; + this.subscriptionId = null; + this.notificationSequenceNumber = 0; + this.lastReportedStatus = null; + this.notificationLoopPromise = null; + this.httpRequestUrl = null; + this.ippPrinterUri = null; + this.pendingIppJobs = new Map(); + this.appliedDefaultMediaKey = null; + } + + getAuth() { + const connection = this.documentPrinterClient.connection || {}; + if (!connection.username) { + return null; + } + + return { + username: connection.username, + password: connection.password || "", + }; + } + + getRawIppRequestOptions() { + const auth = this.getAuth(); + return { + auth, + rejectUnauthorized: false, + allowTlsUpgrade: !auth, + }; + } + + logNotificationDebug(message, data) { + if (data === undefined) { + logger.debug(`[${this.name}] ${message}`); + return; + } + logger.debug(`[${this.name}] ${message}`, data); + } + + usesRawIppTransport() { + return this.getAuth() != null; + } + + async resolveDocumentSize(documentSize) { + if (!documentSize) { + return null; + } + + if (documentSize.width != null && documentSize.height != null) { + return documentSize; + } + + const documentSizeId = getDocumentSizeId(documentSize); + if (!documentSizeId) { + return null; + } + + return this.documentPrinterClient.socketClient.getObject({ + objectType: "documentSize", + _id: documentSizeId, + }); + } + + buildCupsAdminUrl() { + const useTls = this.getAuth() != null; + return buildCupsServerUrl(this.host, this.port, useTls); + } + + buildCupsPpdHttpUrl() { + const useTls = this.getAuth() != null; + return buildCupsPpdUrl(this.host, this.port, useTls); + } + + async setDefaultMediaFromDocumentSize(documentSize) { + if (!this.isConnected || !this.cupsPrinter) { + return { error: "Printer is not connected" }; + } + + const resolvedDocumentSize = await this.resolveDocumentSize(documentSize); + if (!resolvedDocumentSize) { + this.appliedDefaultMediaKey = null; + logger.debug( + `No current document size to apply for CUPS printer ${this.name}`, + ); + return { success: true }; + } + + const rotateOrientation = + this.documentPrinterClient.documentPrinter?.rotateOrientation == true; + const pageSize = buildCupsPageSizeName( + resolvedDocumentSize, + rotateOrientation, + ); + if (!pageSize) { + return { + error: `Invalid document size dimensions for ${resolvedDocumentSize._id}`, + }; + } + + const media = buildCupsPwgMediaName(pageSize); + const documentSizeId = getDocumentSizeId(resolvedDocumentSize); + const mediaKey = `${documentSizeId}:${rotateOrientation ? "1" : "0"}:${pageSize}`; + if (mediaKey === this.appliedDefaultMediaKey) { + return { success: true }; + } + + const auth = this.getAuth(); + const userName = auth?.username || process.env.USER || "system"; + const requestOptions = this.getRawIppRequestOptions(); + const ppdUrl = this.buildCupsPpdHttpUrl(); + const adminUrl = this.buildCupsAdminUrl(); + const printerUri = + this.ippPrinterUri || + buildIppPrinterUri(this.host, this.port, auth != null); + + logger.info( + `Setting CUPS defaults for ${this.name} to PageSize=${pageSize}, sides=one-sided, job-sheets=none,none` + + (rotateOrientation ? " (rotated orientation)" : ""), + ); + + // CUPS only persists queue PageSize defaults by rewriting the PPD and uploading + // it with CUPS-Add-Modify-Printer (same path as lpadmin / web "Set Default Options"). + // Sending media in job-attributes-tag returns successful-ok but leaves *DefaultPageSize unchanged. + const currentPpd = await fetchCupsResource(ppdUrl, requestOptions); + const currentPageSize = getPpdDefaultPageSize(currentPpd); + const updatedPpd = applyCupsPageSizeDefaults(currentPpd, pageSize); + + if (currentPageSize === pageSize && updatedPpd === currentPpd) { + this.appliedDefaultMediaKey = mediaKey; + logger.info( + `CUPS defaults for ${this.name} already set to PageSize=${pageSize}`, + ); + return { success: true, media, pageSize }; + } + + const requestBody = Buffer.concat([ + ipp.serialize( + this.cupsPrinter._message("CUPS-Add-Modify-Printer", { + "operation-attributes-tag": { + "requesting-user-name": userName, + "printer-uri": printerUri, + }, + "printer-attributes-tag": { + "job-sheets-default": ["none", "none"], + "sides-default": "one-sided", + "media-default": media, + }, + }), + ), + Buffer.from(updatedPpd, "utf8"), + ]); + + const response = await sendRawIppRequest(adminUrl, requestBody, { + ...requestOptions, + allowTlsUpgrade: false, + }); + + if (!isSuccessfulIppStatus(response.statusCode)) { + const statusMessage = + response["operation-attributes-tag"]?.["status-message"] || + response.statusCode; + return { + error: `Failed to set CUPS default media: ${statusMessage}`, + }; + } + + const verifiedPpd = await fetchCupsResource(ppdUrl, requestOptions); + const verifiedPageSize = getPpdDefaultPageSize(verifiedPpd); + if (verifiedPageSize !== pageSize) { + return { + error: `CUPS accepted default update but PPD still has PageSize=${verifiedPageSize || "unknown"} (expected ${pageSize})`, + }; + } + + this.appliedDefaultMediaKey = mediaKey; + logger.info( + `Updated CUPS defaults for ${this.name} to PageSize=${pageSize} (media=${media}, job-sheets=none,none, sides=one-sided)`, + ); + return { success: true, media, pageSize }; + } + + async handleCurrentDocumentSizeChange(documentSize) { + try { + return await this.setDefaultMediaFromDocumentSize(documentSize); + } catch (error) { + logger.error( + `Failed to update CUPS default media for ${this.name}:`, + error, + ); + return { error: error.message || "Failed to update CUPS default media" }; + } } /** @@ -52,6 +363,21 @@ export default class CupsInterface { * Promisify IPP execute method */ async executeIPP(operation, message) { + if (this.usesRawIppTransport()) { + if (!this.cupsPrinter || !this.httpRequestUrl) { + throw new Error("Printer is not connected"); + } + + const requestBody = ipp.serialize( + this.cupsPrinter._message(operation, message), + ); + return sendRawIppRequest( + this.httpRequestUrl, + requestBody, + this.getRawIppRequestOptions(), + ); + } + return new Promise((resolve, reject) => { this.cupsPrinter.execute(operation, message, (err, res) => { if (err) { @@ -68,7 +394,16 @@ export default class CupsInterface { try { this.cupsPrinterUrl = this.buildPrinterUrl(); + const useTls = this.getAuth() != null; + this.httpRequestUrl = buildHttpRequestUrl( + this.host, + this.port, + this.protocol, + useTls, + ); + this.ippPrinterUri = buildIppPrinterUri(this.host, this.port, useTls); logger.debug(`Printer URL: ${this.cupsPrinterUrl}`); + logger.debug(`HTTP request URL: ${this.httpRequestUrl}`); // Create IPP printer instance this.cupsPrinter = ipp.Printer(this.cupsPrinterUrl); @@ -88,13 +423,13 @@ export default class CupsInterface { try { const response = await this.executeIPP( "Get-Printer-Attributes", - getPrinterAttributes + getPrinterAttributes, ); const printerState = response["printer-attributes-tag"]?.["printer-state"]; logger.info( - `Successfully connected to CUPS printer ${this.name}. State: ${printerState}` + `Successfully connected to CUPS printer ${this.name}. State: ${printerState}`, ); this.isConnected = true; @@ -103,7 +438,7 @@ export default class CupsInterface { } catch (error) { logger.error( `Failed to get printer attributes for ${this.name}:`, - error + error, ); return { error: "Failed to get printer attributes. " + error.message, @@ -121,8 +456,13 @@ export default class CupsInterface { async disconnect() { startWaiting("Disconnecting from CUPS printer...", logger); + await this.stopStatusMonitoring(); + this.pendingIppJobs.clear(); + this.appliedDefaultMediaKey = null; this.isConnected = false; this.cupsPrinter = null; + this.httpRequestUrl = null; + this.ippPrinterUri = null; await stopWaiting(); return { success: true }; } @@ -149,7 +489,7 @@ export default class CupsInterface { const response = await this.executeIPP( "Get-Printer-Attributes", - getPrinterAttributes + getPrinterAttributes, ); const printerState = @@ -165,8 +505,25 @@ export default class CupsInterface { } logger.info( - `CUPS printer ${this.name} initialized successfully. State: ${printerState}` + `CUPS printer ${this.name} initialized successfully. State: ${printerState}`, ); + + try { + const mediaResult = await this.setDefaultMediaFromDocumentSize( + this.documentPrinterClient.documentPrinter?.currentDocumentSize, + ); + if (mediaResult?.error) { + logger.warn( + `Failed to apply current document size defaults for ${this.name}: ${mediaResult.error}`, + ); + } + } catch (error) { + logger.warn( + `Failed to apply current document size defaults for ${this.name}:`, + error, + ); + } + await stopWaiting(); return true; } catch (error) { @@ -190,7 +547,7 @@ export default class CupsInterface { if (this.documentPrinterClient.socketClient?.fileManager) { data = await this.documentPrinterClient.socketClient.fileManager.getFile( - fileId + fileId, ); } else { throw new Error("File manager not available to fetch file"); @@ -221,7 +578,7 @@ export default class CupsInterface { data = Buffer.from(data, "utf-8"); } else { throw new Error( - "Document data must be a Buffer, Uint8Array, ArrayBuffer, or string" + "Document data must be a Buffer, Uint8Array, ArrayBuffer, or string", ); } } @@ -231,21 +588,22 @@ export default class CupsInterface { async deploy(documentJob, documentTemplate, object, onProgress) { logger.info( - `Deploying job ${documentJob._id} to CUPS printer ${this.name}` + `Deploying job ${documentJob._id} to CUPS printer ${this.name}`, ); - const pdfObj = await this.documentPrinterClient.socketClient.renderTemplatePDF( - { - _id: documentTemplate._id, - content: documentTemplate.content, - object: object, - }, - onProgress, - ); + const pdfObj = + await this.documentPrinterClient.socketClient.renderTemplatePDF( + { + _id: documentTemplate._id, + content: documentTemplate.content, + object: object, + }, + onProgress, + ); if (!pdfObj || !pdfObj.pdf) { throw new Error( - pdfObj?.error || "Failed to render document template to PDF" + pdfObj?.error || "Failed to render document template to PDF", ); } @@ -256,7 +614,7 @@ export default class CupsInterface { async print(jobId, quantity = 1) { const copies = Math.max(1, Math.floor(Number(quantity) || 1)); logger.info( - `Printing job ${jobId} (${copies} ${copies === 1 ? "copy" : "copies"}) to CUPS printer ${this.name}` + `Printing job ${jobId} (${copies} ${copies === 1 ? "copy" : "copies"}) to CUPS printer ${this.name}`, ); if (!this.isConnected || !this.cupsPrinter) { @@ -280,7 +638,7 @@ export default class CupsInterface { documentData = Buffer.from(documentData, "utf-8"); } else { throw new Error( - "PDF data must be a Buffer, Uint8Array, ArrayBuffer, or string" + "PDF data must be a Buffer, Uint8Array, ArrayBuffer, or string", ); } } @@ -301,18 +659,25 @@ export default class CupsInterface { // Send one print job per copy so quantity is honored even when the // printer does not support the IPP copies attribute. logger.debug( - `Sending print job ${jobId} (${copies} ${copies === 1 ? "copy" : "copies"}) to ${this.cupsPrinterUrl}` + `Sending print job ${jobId} (${copies} ${copies === 1 ? "copy" : "copies"}) to ${this.cupsPrinterUrl}`, ); let ippJobId; let jobUri; for (let copy = 0; copy < copies; copy++) { const response = await this.executeIPP("Print-Job", printJobMessage); - ippJobId = response["job-attributes-tag"]?.["job-id"]; - jobUri = response["job-attributes-tag"]?.["job-uri"]; + const jobAttributes = response["job-attributes-tag"] || {}; + ippJobId = jobAttributes["job-id"]; + jobUri = jobAttributes["job-uri"]; + + await this.waitForIppJobCompletion({ + ippJobId, + jobUri, + documentJobId: jobId, + }); } logger.info( - `Successfully printed job ${jobId} (${copies} ${copies === 1 ? "copy" : "copies"}) to CUPS printer ${this.name}. IPP Job ID: ${ippJobId}` + `Successfully printed job ${jobId} (${copies} ${copies === 1 ? "copy" : "copies"}) to CUPS printer ${this.name}. IPP Job ID: ${ippJobId}`, ); return { @@ -324,7 +689,7 @@ export default class CupsInterface { } catch (error) { logger.error( `Failed to print job ${jobId} to CUPS printer ${this.name}:`, - error + error, ); return { success: false, @@ -332,4 +697,646 @@ export default class CupsInterface { }; } } + + async retrieveStatus() { + logger.debug(`Getting status of CUPS printer ${this.name}`); + + if (!this.isConnected || !this.cupsPrinter) { + return buildStatusError("Printer is not connected"); + } + + try { + const response = await this.executeIPP("Get-Printer-Attributes", { + "operation-attributes-tag": { + "requested-attributes": [ + "printer-state", + "printer-state-message", + "printer-state-reasons", + ], + }, + }); + + const attributes = response["printer-attributes-tag"] || {}; + const status = mapCupsPrinterStatus( + attributes["printer-state"], + attributes["printer-state-message"], + attributes["printer-state-reasons"], + ); + + logger.debug(`CUPS printer ${this.name} status:`, status); + return status; + } catch (error) { + logger.error(`Failed to retrieve CUPS printer status:`, error); + return buildStatusError( + error.message || "Failed to retrieve printer status", + ); + } + } + + isTerminalIppJobState(jobState) { + return jobState === 9 || jobState === 6 || jobState === 7 || jobState === 8; + } + + async getIppJobState({ ippJobId, jobUri }) { + const operationAttributes = { + "requested-attributes": ["job-state", "job-state-reasons"], + }; + + if (jobUri) { + operationAttributes["job-uri"] = jobUri; + } else if (ippJobId != null) { + operationAttributes["job-id"] = ippJobId; + } else { + throw new Error("IPP job id or job uri is required"); + } + + const response = await this.executeIPP("Get-Job-Attributes", { + "operation-attributes-tag": operationAttributes, + }); + + const attributes = response["job-attributes-tag"] || {}; + return { + jobState: attributes["job-state"], + jobStateReasons: attributes["job-state-reasons"], + }; + } + + resolvePendingIppJob(pendingKey, error = null) { + const pending = this.pendingIppJobs.get(pendingKey); + if (!pending) { + return; + } + + this.pendingIppJobs.delete(pendingKey); + if (error) { + pending.reject(error); + } else { + pending.resolve(); + } + } + + async waitForIppJobCompletion({ ippJobId, jobUri, documentJobId }) { + const pendingKey = ippJobId ?? documentJobId; + const notificationPromise = + pendingKey != null + ? new Promise((resolve, reject) => { + this.pendingIppJobs.set(pendingKey, { + resolve, + reject, + documentJobId, + }); + }) + : null; + + const pollPromise = (async () => { + const deadline = Date.now() + IPP_JOB_COMPLETION_TIMEOUT_MS; + + while (Date.now() < deadline) { + let jobState; + let jobStateReasons; + + try { + ({ jobState, jobStateReasons } = await this.getIppJobState({ + ippJobId, + jobUri, + })); + } catch (error) { + this.logNotificationDebug("Failed to poll IPP job state", { + ippJobId, + jobUri, + message: error.message, + }); + await new Promise((resolve) => + setTimeout(resolve, IPP_JOB_POLL_INTERVAL_MS), + ); + continue; + } + + const mappedState = mapCupsJobState(jobState, jobStateReasons); + if (mappedState && documentJobId) { + await this.documentPrinterClient.updateJobState( + documentJobId, + mappedState, + ); + } + + if (jobState === 9) { + return; + } + + if (jobState === 6 || jobState === 7 || jobState === 8) { + throw new Error( + mappedState?.message || `IPP job ended with state ${jobState}`, + ); + } + + await new Promise((resolve) => + setTimeout(resolve, IPP_JOB_POLL_INTERVAL_MS), + ); + } + + throw new Error( + `Timed out waiting for CUPS job ${ippJobId ?? documentJobId} to complete`, + ); + })(); + + try { + if (notificationPromise) { + await Promise.race([notificationPromise, pollPromise]); + } else { + await pollPromise; + } + } finally { + if (pendingKey != null) { + this.pendingIppJobs.delete(pendingKey); + } + } + } + + buildPrinterStatusFromNotificationEvent(eventAttributes) { + if (eventAttributes?.["printer-state"] == null) { + return null; + } + + return mapCupsPrinterStatus( + eventAttributes["printer-state"], + eventAttributes["printer-state-message"], + eventAttributes["printer-state-reasons"], + ); + } + + async handleJobNotificationEvent(eventAttributes) { + const documentJobId = eventAttributes?.["job-name"]; + const ippJobId = eventAttributes?.["notify-job-id"]; + const jobState = eventAttributes?.["job-state"]; + + if (jobState == null) { + return; + } + + const mappedState = mapCupsJobState( + jobState, + eventAttributes["job-state-reasons"], + ); + + if (mappedState && documentJobId) { + this.logNotificationDebug("Updating document job state from notification", { + documentJobId, + ippJobId, + jobState, + mappedState, + }); + await this.documentPrinterClient.updateJobState( + documentJobId, + mappedState, + ); + } + + if (this.isTerminalIppJobState(jobState)) { + if (ippJobId != null) { + this.resolvePendingIppJob( + ippJobId, + jobState === 9 + ? null + : new Error(mappedState?.message || `IPP job ended with state ${jobState}`), + ); + } + if (documentJobId != null) { + this.resolvePendingIppJob( + documentJobId, + jobState === 9 + ? null + : new Error(mappedState?.message || `IPP job ended with state ${jobState}`), + ); + } + } + } + + async notifyPrinterStatusFromEvent(eventAttributes) { + const status = this.buildPrinterStatusFromNotificationEvent(eventAttributes); + if (!status) { + return; + } + + this.logNotificationDebug("Applying printer status from notification event", status); + + if (!printerStatusesEqual(this.lastReportedStatus, status)) { + this.lastReportedStatus = status; + await this.documentPrinterClient.handlePrinterStatusUpdate(status); + } + } + + async executeRawNotificationIpp(operation, buildRequest) { + if (!this.httpRequestUrl || !this.ippPrinterUri) { + throw new Error("Printer notification endpoints are not configured"); + } + + const requestOptions = this.getRawIppRequestOptions(); + this.logNotificationDebug(`IPP notification request: ${operation}`, { + httpRequestUrl: this.httpRequestUrl, + ippPrinterUri: this.ippPrinterUri, + authenticated: requestOptions.auth != null, + username: requestOptions.auth?.username ?? null, + }); + + const requestBody = buildRequest(this.ippPrinterUri); + const response = await sendRawIppRequest( + this.httpRequestUrl, + requestBody, + requestOptions, + ); + + this.logNotificationDebug(`IPP notification response: ${operation}`, { + statusCode: response.statusCode, + id: response.id, + operationAttributes: response["operation-attributes-tag"], + subscriptionAttributes: response["subscription-attributes-tag"], + eventNotificationAttributes: + response["event-notification-attributes-tag"], + }); + + return response; + } + + parseNotificationEvents(response) { + const rawEvents = response["event-notification-attributes-tag"]; + if (!rawEvents) { + this.logNotificationDebug("No notification events in IPP response"); + return []; + } + + const eventTags = Array.isArray(rawEvents) ? rawEvents : [rawEvents]; + const events = eventTags.map((event) => ({ + name: event["notify-subscribed-event"], + sequenceNumber: event["notify-sequence-number"], + attributes: event, + })); + this.logNotificationDebug("Parsed IPP notification events", events); + return events; + } + + async createPrinterSubscription() { + const response = await this.executeRawNotificationIpp( + "Create-Printer-Subscriptions", + (printerUri) => + buildCreatePrinterSubscriptionRequest(printerUri, { + leaseDuration: NOTIFICATION_LEASE_DURATION, + }), + ); + + if (!isSuccessfulIppStatus(response.statusCode)) { + const statusMessage = + response["operation-attributes-tag"]?.["status-message"] || + response.statusCode; + throw new Error( + `CUPS rejected printer subscription request: ${statusMessage}`, + ); + } + + const subscriptionId = extractSubscriptionId(response); + + if (subscriptionId == null) { + this.logNotificationDebug( + "Create-Printer-Subscriptions missing subscription id", + response, + ); + throw new Error("CUPS did not return a notification subscription id"); + } + + this.subscriptionId = subscriptionId; + this.notificationSequenceNumber = 0; + this.logNotificationDebug("Created printer subscription", { + subscriptionId, + sequenceNumber: this.notificationSequenceNumber, + }); + logger.info( + `Created CUPS notification subscription ${subscriptionId} for ${this.name}`, + ); + } + + async cancelPrinterSubscription() { + if (this.subscriptionId == null || !this.httpRequestUrl) { + this.logNotificationDebug("Skipping cancel: no active subscription"); + return; + } + + const subscriptionId = this.subscriptionId; + this.logNotificationDebug("Cancelling printer subscription", { + subscriptionId, + }); + + try { + const response = await this.executeRawNotificationIpp( + "Cancel-Subscription", + (printerUri) => + buildCancelSubscriptionRequest(printerUri, subscriptionId), + ); + + if (!isSuccessfulIppStatus(response.statusCode)) { + const statusMessage = + response["operation-attributes-tag"]?.["status-message"] || + response.statusCode; + throw new Error( + `CUPS Cancel-Subscription failed: ${statusMessage}`, + ); + } + + logger.info( + `Cancelled CUPS notification subscription ${subscriptionId} for ${this.name}`, + ); + } catch (error) { + logger.warn( + `Failed to cancel CUPS notification subscription for ${this.name}:`, + error, + ); + } finally { + this.subscriptionId = null; + this.notificationSequenceNumber = 0; + } + } + + async getNotifications() { + if (this.subscriptionId == null) { + throw new Error("No active CUPS notification subscription"); + } + + const subscriptionId = this.subscriptionId; + const sequenceNumber = this.notificationSequenceNumber; + + this.logNotificationDebug("Polling Get-Notifications", { + subscriptionId, + sequenceNumber, + }); + + return this.executeRawNotificationIpp("Get-Notifications", (printerUri) => + buildGetNotificationsRequest( + printerUri, + subscriptionId, + sequenceNumber, + null, + ), + ); + } + + async probeNotificationSupport() { + this.logNotificationDebug("Probing CUPS notification support"); + await this.createPrinterSubscription(); + + try { + const response = await this.getNotifications(); + if (!isSuccessfulIppStatus(response.statusCode)) { + throw new Error( + response["operation-attributes-tag"]?.["status-message"] || + response.statusCode, + ); + } + this.logNotificationDebug("CUPS notification probe succeeded"); + return true; + } catch (error) { + this.logNotificationDebug("CUPS notification probe failed", { + message: error.message, + statusCode: error.statusCode, + }); + logger.warn( + `CUPS notification polling is unavailable for ${this.name}:`, + error, + ); + await this.cancelPrinterSubscription(); + return false; + } + } + + getNotificationPollIntervalMs(response) { + const interval = + response?.["operation-attributes-tag"]?.["notify-get-interval"]; + if (interval == null) { + return STATUS_POLL_INTERVAL_MS; + } + return Math.max(1, Number(interval)) * 1000; + } + + isUnsupportedNotificationError(error) { + return error?.statusCode === 426 || error?.statusCode === 401; + } + + startPollingFallback() { + if (this.statusPollingInterval) { + return; + } + + this.statusMonitoringMode = "polling"; + logger.warn( + `Falling back to status polling for CUPS printer ${this.name}`, + ); + this.statusPollingInterval = setInterval(() => { + this.refreshAndNotifyStatus().catch((error) => { + logger.error( + `Error polling CUPS printer status for ${this.name}:`, + error, + ); + }); + }, STATUS_POLL_INTERVAL_MS); + } + + stopPollingFallback() { + if (this.statusPollingInterval) { + clearInterval(this.statusPollingInterval); + this.statusPollingInterval = null; + } + } + + async switchToPollingFallback(reason) { + logger.warn(`${reason} for ${this.name}, falling back to status polling`); + await this.cancelPrinterSubscription(); + this.startPollingFallback(); + } + + async refreshAndNotifyStatus() { + const status = await this.retrieveStatus(); + if (status?.error) { + logger.error( + `Failed to refresh CUPS printer status for ${this.name}: ${status.error}`, + ); + return; + } + + if (!printerStatusesEqual(this.lastReportedStatus, status)) { + this.lastReportedStatus = status; + await this.documentPrinterClient.handlePrinterStatusUpdate(status); + } + } + + async handleNotificationResponse(response) { + const events = this.parseNotificationEvents(response); + if (events.length === 0) { + this.logNotificationDebug("Notification poll returned no events"); + return; + } + + let latestPrinterEventAttributes = null; + + for (const event of events) { + this.logNotificationDebug("Processing notification event", { + name: event.name, + sequenceNumber: event.sequenceNumber, + }); + + if (event.sequenceNumber != null) { + const previousSequenceNumber = this.notificationSequenceNumber; + this.notificationSequenceNumber = Math.max( + this.notificationSequenceNumber, + Number(event.sequenceNumber) + 1, + ); + this.logNotificationDebug("Updated notification sequence number", { + previousSequenceNumber, + nextSequenceNumber: this.notificationSequenceNumber, + }); + } + + if ( + event.name === "printer-state-changed" || + event.name === "job-created" + ) { + if (event.attributes?.["printer-state"] != null) { + latestPrinterEventAttributes = event.attributes; + } + } + + if ( + event.name === "job-state-changed" || + event.name === "job-created" || + event.name === "job-completed" + ) { + await this.handleJobNotificationEvent(event.attributes); + } + } + + if (latestPrinterEventAttributes) { + await this.notifyPrinterStatusFromEvent(latestPrinterEventAttributes); + } + } + + async runNotificationLoop() { + this.logNotificationDebug("Starting IPP notification loop"); + while ( + this.statusMonitoringActive && + this.isConnected && + this.httpRequestUrl + ) { + let pollIntervalMs = STATUS_POLL_INTERVAL_MS; + + try { + const response = await this.getNotifications(); + if (!isSuccessfulIppStatus(response.statusCode)) { + const statusMessage = + response["operation-attributes-tag"]?.["status-message"] || + response.statusCode; + throw new Error( + `CUPS Get-Notifications failed: ${statusMessage}`, + ); + } + + await this.handleNotificationResponse(response); + pollIntervalMs = this.getNotificationPollIntervalMs(response); + this.logNotificationDebug("Scheduling next notification poll", { + pollIntervalMs, + notifyGetInterval: + response["operation-attributes-tag"]?.["notify-get-interval"], + }); + } catch (error) { + if (!this.statusMonitoringActive) { + break; + } + + if (this.isUnsupportedNotificationError(error)) { + this.logNotificationDebug( + "Notification transport unsupported, switching to polling fallback", + { + statusCode: error.statusCode, + message: error.message, + }, + ); + await this.switchToPollingFallback( + "CUPS notification requests are not supported", + ); + break; + } + + this.logNotificationDebug("Notification loop error, retrying", { + message: error.message, + statusCode: error.statusCode, + pollIntervalMs: NOTIFICATION_RETRY_DELAY_MS, + }); + logger.error(`CUPS notification loop error for ${this.name}:`, error); + pollIntervalMs = NOTIFICATION_RETRY_DELAY_MS; + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + this.logNotificationDebug("IPP notification loop stopped"); + } + + async startStatusMonitoring() { + if (this.statusMonitoringActive) { + this.logNotificationDebug("Status monitoring already active, skipping"); + return; + } + + if (!this.isConnected || !this.httpRequestUrl) { + logger.warn( + `Cannot start CUPS status monitoring for ${this.name}: printer not connected`, + ); + return; + } + + this.statusMonitoringActive = true; + this.logNotificationDebug("Starting CUPS status monitoring", { + httpRequestUrl: this.httpRequestUrl, + ippPrinterUri: this.ippPrinterUri, + authenticated: this.getAuth() != null, + }); + + try { + await this.refreshAndNotifyStatus(); + const notificationsAvailable = await this.probeNotificationSupport(); + if (!notificationsAvailable) { + this.logNotificationDebug( + "IPP notifications unavailable, using status polling fallback", + ); + this.startPollingFallback(); + return; + } + + this.statusMonitoringMode = "notifications"; + this.logNotificationDebug("IPP notification monitoring enabled"); + this.notificationLoopPromise = this.runNotificationLoop(); + } catch (error) { + this.logNotificationDebug("Failed to start notification monitoring", { + message: error.message, + }); + logger.error( + `Failed to start CUPS notification monitoring for ${this.name}:`, + error, + ); + this.startPollingFallback(); + } + } + + async stopStatusMonitoring() { + this.logNotificationDebug("Stopping CUPS status monitoring", { + mode: this.statusMonitoringMode, + subscriptionId: this.subscriptionId, + }); + this.statusMonitoringActive = false; + this.stopPollingFallback(); + this.statusMonitoringMode = null; + await this.cancelPrinterSubscription(); + + if (this.notificationLoopPromise) { + await this.notificationLoopPromise.catch(() => {}); + this.notificationLoopPromise = null; + } + + this.lastReportedStatus = null; + } } diff --git a/src/documentprinter/interfaces/receiptinterface.js b/src/documentprinter/interfaces/receiptinterface.js index 838d1ea..9f4d1a5 100644 --- a/src/documentprinter/interfaces/receiptinterface.js +++ b/src/documentprinter/interfaces/receiptinterface.js @@ -1,7 +1,17 @@ // receiptinterface.js - Thermal receipt printer interface implementation +import net from "net"; import log4js from "log4js"; import { loadConfig } from "../../config.js"; import { ThermalPrinter, PrinterTypes } from "node-thermal-printer"; +import { + buildStatusError, + mapReceiptPrinterStatus, + printerStatusesEqual, +} from "../printerstatus.js"; + +const STATUS_TIMEOUT_MS = 5000; +const STATUS_POLL_INTERVAL_MS = 1000; +const VALID_STATUS_MASK = 0x12; const config = loadConfig(); const logger = log4js.getLogger("Receipt Printer Interface"); @@ -18,7 +28,8 @@ export default class ReceiptInterface { this.protocol = documentPrinterClient.connection.protocol; this.isConnected = false; this.receiptPrinter = null; - this.retrieveStatusInterval = null; + this.statusMonitoringInterval = null; + this.lastReportedStatus = null; this.images = new Map(); } @@ -50,7 +61,7 @@ export default class ReceiptInterface { default: type = PrinterTypes.EPSON; logger.warn( - `Unknown interface ${this.interface}, defaulting to EPSON` + `Unknown interface ${this.interface}, defaulting to EPSON`, ); } @@ -58,7 +69,7 @@ export default class ReceiptInterface { const interfaceStr = this.buildPrinterUrl(); logger.info( - `Connecting to receipt printer ${this.name} (${interfaceStr})` + `Connecting to receipt printer ${this.name} (${interfaceStr})`, ); // Initialize thermal printer @@ -91,6 +102,7 @@ export default class ReceiptInterface { async disconnect() { logger.info(`Disconnecting from receipt printer ${this.name}`); + await this.stopStatusMonitoring(); this.isConnected = false; this.receiptPrinter = null; return { success: true }; @@ -105,7 +117,7 @@ export default class ReceiptInterface { const isConnected = await this.receiptPrinter.isPrinterConnected(); if (!isConnected) { logger.error( - `Printer not connected during initialization for receipt printer ${this.name}` + `Printer not connected during initialization for receipt printer ${this.name}`, ); return { error: "Printer not connected during initialization" }; } @@ -113,7 +125,7 @@ export default class ReceiptInterface { } catch (error) { logger.error( `Failed to initialize receipt printer ${this.name}:`, - error + error, ); } } @@ -123,7 +135,7 @@ export default class ReceiptInterface { async print(jobId, quantity = 1) { const copies = Math.max(1, Math.floor(Number(quantity) || 1)); logger.info( - `Printing job ${jobId} (${copies} ${copies === 1 ? "copy" : "copies"}) to receipt printer ${this.name}` + `Printing job ${jobId} (${copies} ${copies === 1 ? "copy" : "copies"}) to receipt printer ${this.name}`, ); if (!this.isConnected || !this.receiptPrinter) { @@ -155,14 +167,14 @@ export default class ReceiptInterface { await this.receiptPrinter.execute({ waitForResponse: true }); logger.info( - `Successfully printed job ${jobId} (${copies} ${copies === 1 ? "copy" : "copies"}) to receipt printer ${this.name}` + `Successfully printed job ${jobId} (${copies} ${copies === 1 ? "copy" : "copies"}) to receipt printer ${this.name}`, ); return { success: true, copies }; } catch (error) { logger.error( `Failed to print job ${jobId} to receipt printer ${this.name}:`, - error + error, ); return { success: false, error: error.message }; } @@ -170,7 +182,7 @@ export default class ReceiptInterface { async deploy(documentJob, documentTemplate, object, onProgress) { logger.info( - `Deploying job ${documentJob._id} to receipt printer ${this.name}` + `Deploying job ${documentJob._id} to receipt printer ${this.name}`, ); const imageObj = @@ -187,7 +199,7 @@ export default class ReceiptInterface { if (!imageObj || !imageObj.images) { throw new Error( - imageObj?.error || "Failed to render document template to image" + imageObj?.error || "Failed to render document template to image", ); } @@ -195,22 +207,180 @@ export default class ReceiptInterface { return { success: true }; } + isValidStatusByte(byte) { + return (byte & VALID_STATUS_MASK) === VALID_STATUS_MASK; + } + + parsePrinterStatus(byte) { + return { + raw: byte, + drawerKickPin3High: Boolean(byte & 0x04), + offline: Boolean(byte & 0x08), + waitingForOnlineRecovery: Boolean(byte & 0x20), + paperFeedButtonPressed: Boolean(byte & 0x40), + }; + } + + parseOfflineStatus(byte) { + return { + raw: byte, + coverOpen: Boolean(byte & 0x04), + paperFeedButtonActive: Boolean(byte & 0x08), + paperEndStop: Boolean(byte & 0x20), + errorOccurred: Boolean(byte & 0x40), + }; + } + + parseErrorStatus(byte) { + return { + raw: byte, + recoverableError: Boolean(byte & 0x04), + autocutterError: Boolean(byte & 0x08), + unrecoverableError: Boolean(byte & 0x20), + autoRecoverableError: Boolean(byte & 0x40), + }; + } + + parsePaperStatus(byte) { + return { + raw: byte, + paperNearEnd: (byte & 0x0c) === 0x0c, + paperEnd: (byte & 0x60) === 0x60, + }; + } + + sendDleEotStatus(requests) { + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + const command = Buffer.from(requests.flatMap((n) => [0x10, 0x04, n])); + const expectedBytes = requests.length; + let received = Buffer.alloc(0); + let settled = false; + + const cleanup = () => { + socket.removeAllListeners(); + socket.destroy(); + }; + + const finish = (callback) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + cleanup(); + callback(); + }; + + const timeout = setTimeout(() => { + finish(() => + reject(new Error("Timed out waiting for printer status response")), + ); + }, STATUS_TIMEOUT_MS); + + socket.on("data", (data) => { + received = Buffer.concat([received, data]); + if (received.length < expectedBytes) { + return; + } + + finish(() => resolve(received.subarray(0, expectedBytes))); + }); + + socket.on("error", (error) => { + finish(() => reject(error)); + }); + + socket.connect(this.port, this.host, () => { + socket.write(command); + }); + }); + } + async retrieveStatus() { - logger.debug(`Getting status of receipt printer ${this.name}`); - if (this.isOnline == false) { + logger.trace(`Getting status of receipt printer ${this.name}`); + + if (!this.isConnected) { 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); + return buildStatusError("Printer is not connected or not reachable."); } - logger.debug(`Receipt printer ${this.name} is connected.`); - return true; + if (this.protocol && this.protocol !== "tcp") { + return { + paperState: { + type: "unknown", + message: "Real-time printer status is only supported over TCP.", + }, + }; + } + + try { + const response = await this.sendDleEotStatus([1, 2, 3, 4]); + + for (let i = 0; i < response.length; i++) { + if (!this.isValidStatusByte(response[i])) { + throw new Error( + `Invalid status response byte at index ${i}: 0x${response[i].toString(16)}`, + ); + } + } + + const printer = this.parsePrinterStatus(response[0]); + const offline = this.parseOfflineStatus(response[1]); + const error = this.parseErrorStatus(response[2]); + const paper = this.parsePaperStatus(response[3]); + + const status = mapReceiptPrinterStatus({ + printer, + offline, + error, + paper, + }); + + logger.trace(`Receipt printer ${this.name} status:`, status); + return status; + } catch (error) { + logger.error(`Failed to retrieve printer status:`, error); + return buildStatusError(error.message); + } + } + + async refreshAndNotifyStatus() { + const status = await this.retrieveStatus(); + if (status?.error) { + logger.error( + `Failed to refresh receipt printer status for ${this.name}: ${status.error}`, + ); + return; + } + + if (!printerStatusesEqual(this.lastReportedStatus, status)) { + this.lastReportedStatus = status; + await this.documentPrinterClient.handlePrinterStatusUpdate(status); + } + } + + async startStatusMonitoring() { + if (this.statusMonitoringInterval) { + return; + } + + await this.refreshAndNotifyStatus(); + this.statusMonitoringInterval = setInterval(() => { + this.refreshAndNotifyStatus().catch((error) => { + logger.error( + `Error polling receipt printer status for ${this.name}:`, + error, + ); + }); + }, STATUS_POLL_INTERVAL_MS); + } + + async stopStatusMonitoring() { + if (this.statusMonitoringInterval) { + clearInterval(this.statusMonitoringInterval); + this.statusMonitoringInterval = null; + } + this.lastReportedStatus = null; } } diff --git a/src/documentprinter/printerstatus.js b/src/documentprinter/printerstatus.js new file mode 100644 index 0000000..678255c --- /dev/null +++ b/src/documentprinter/printerstatus.js @@ -0,0 +1,170 @@ +/** + * Standard shape returned by printer interface retrieveStatus(): + * { + * state: { type: string, message?: string }, + * paperState: { type: string, message?: string }, + * } + * + * On failure: { error: string } + */ + +export function buildPrinterStatus({ stateType, stateMessage, paperStateType, paperStateMessage }) { + return { + state: { + type: stateType, + ...(stateMessage ? { message: stateMessage } : {}), + }, + paperState: { + type: paperStateType, + ...(paperStateMessage ? { message: paperStateMessage } : {}), + }, + }; +} + +export function buildStatusError(message) { + return { error: message }; +} + +export function printerStatusesEqual(a, b) { + if (!a && !b) { + return true; + } + if (!a || !b) { + return false; + } + if (a.error || b.error) { + return false; + } + return ( + a.state?.type === b.state?.type && + a.state?.message === b.state?.message && + a.paperState?.type === b.paperState?.type && + a.paperState?.message === b.paperState?.message + ); +} + +export function mapCupsJobState(jobState, stateReasons = []) { + const reasons = (Array.isArray(stateReasons) ? stateReasons : [stateReasons]) + .filter(Boolean) + .map((reason) => reason.toLowerCase()); + + if (jobState === 5) { + return { type: "printing" }; + } + + if (jobState === 9) { + return { type: "complete" }; + } + + if (jobState === 3 || jobState === 4) { + return { type: "queued" }; + } + + if (jobState === 6 || jobState === 7 || jobState === 8) { + const message = + reasons.find((reason) => reason !== "none") || + (jobState === 7 ? "Job canceled" : "Print job failed"); + return { type: "failed", message }; + } + + return null; +} + +export function mapCupsPrinterStatus(printerState, stateMessage, stateReasons = []) { + const reasons = (Array.isArray(stateReasons) ? stateReasons : [stateReasons]) + .filter(Boolean) + .map((reason) => reason.toLowerCase()); + + let stateType = "standby"; + let resolvedStateMessage = stateMessage || null; + + if (printerState === 4) { + stateType = "printing"; + } else if (printerState === 5) { + stateType = "error"; + } + + let paperStateType = "ok"; + let paperStateMessage = null; + + if (reasons.some((reason) => reason.includes("media-empty"))) { + paperStateType = "out"; + paperStateMessage = "Paper out"; + } else if ( + reasons.some( + (reason) => reason.includes("media-low") || reason.includes("media-needed"), + ) + ) { + paperStateType = "nearEnd"; + paperStateMessage = "Paper low"; + } + + if (reasons.some((reason) => reason.includes("door-open"))) { + stateType = "error"; + resolvedStateMessage = resolvedStateMessage || "Door open"; + } else if ( + reasons.some( + (reason) => + reason.includes("offline") || + reason.includes("shutdown") || + reason.includes("stopped-partly"), + ) + ) { + stateType = "offline"; + resolvedStateMessage = resolvedStateMessage || "Printer offline"; + } else if ( + reasons.some((reason) => reason.includes("media-jam") || reason.includes("jam")) + ) { + stateType = "error"; + resolvedStateMessage = resolvedStateMessage || "Paper jam"; + } + + return buildPrinterStatus({ + stateType, + stateMessage: resolvedStateMessage, + paperStateType, + paperStateMessage, + }); +} + +export function mapReceiptPrinterStatus({ printer, offline, error, paper }) { + let stateType = "standby"; + let stateMessage = null; + let paperStateType = "ok"; + let paperStateMessage = null; + + if (paper?.paperEnd) { + paperStateType = "out"; + paperStateMessage = "Paper out"; + } else if (paper?.paperNearEnd) { + paperStateType = "nearEnd"; + paperStateMessage = "Paper near end"; + } + + if (offline?.coverOpen) { + stateType = "error"; + stateMessage = "Cover open"; + } else if (error?.unrecoverableError) { + stateType = "error"; + stateMessage = "Unrecoverable error"; + } else if (error?.recoverableError || error?.autoRecoverableError) { + stateType = "error"; + stateMessage = "Recoverable error"; + } else if (error?.autocutterError) { + stateType = "error"; + stateMessage = "Autocutter error"; + } else if (printer?.offline) { + stateType = "offline"; + stateMessage = "Printer offline"; + } else if (offline?.errorOccurred) { + stateType = "error"; + stateMessage = "Printer error"; + } + + return buildPrinterStatus({ + stateType, + stateMessage, + paperStateType, + paperStateMessage, + }); +} diff --git a/src/documentprinter/rawippnotifications.js b/src/documentprinter/rawippnotifications.js new file mode 100644 index 0000000..cadf01a --- /dev/null +++ b/src/documentprinter/rawippnotifications.js @@ -0,0 +1,577 @@ +import http from "http"; +import https from "https"; + +const IPP_VERSION = 0x0200; + +export const IPP_OPERATIONS = { + CREATE_PRINTER_SUBSCRIPTIONS: 0x0016, + CANCEL_SUBSCRIPTION: 0x001b, + GET_NOTIFICATIONS: 0x001c, +}; + +const IPP_TAG = { + OPERATION: 0x01, + SUBSCRIPTION: 0x06, + EVENT_NOTIFICATION: 0x07, + END: 0x03, +}; + +const IPP_VALUE = { + INTEGER: 0x21, + BOOLEAN: 0x22, + CHARSET: 0x47, + NATURAL_LANGUAGE: 0x48, + URI: 0x45, + KEYWORD: 0x44, +}; + +const TAG_NAMES = { + [IPP_TAG.OPERATION]: "operation-attributes-tag", + 0x02: "job-attributes-tag", + 0x04: "printer-attributes-tag", + 0x05: "unsupported-attributes-tag", + [IPP_TAG.SUBSCRIPTION]: "subscription-attributes-tag", + 0x07: "event-notification-attributes-tag", +}; + +const STATUS_CODES = { + 0x0000: "successful-ok", + 0x0001: "successful-ok-ignored-or-substituted-attributes", + 0x0002: "successful-ok-conflicting-attributes", + 0x0003: "successful-ok-ignored-subscriptions", +}; + +class IppBufferWriter { + constructor() { + this.chunks = []; + this.length = 0; + } + + append(buffer) { + this.chunks.push(buffer); + this.length += buffer.length; + } + + writeUInt8(value) { + const buffer = Buffer.alloc(1); + buffer.writeUInt8(value); + this.append(buffer); + } + + writeUInt16BE(value) { + const buffer = Buffer.alloc(2); + buffer.writeUInt16BE(value); + this.append(buffer); + } + + writeUInt32BE(value) { + const buffer = Buffer.alloc(4); + buffer.writeUInt32BE(value); + this.append(buffer); + } + + writeString(value, encoding = "utf8") { + const buffer = Buffer.from(value, encoding); + this.writeUInt16BE(buffer.length); + this.append(buffer); + } + + toBuffer() { + return Buffer.concat(this.chunks, this.length); + } +} + +function writeAttribute( + writer, + valueTag, + name, + value, + isAdditionalValue = false, +) { + writer.writeUInt8(valueTag); + if (isAdditionalValue) { + writer.writeUInt16BE(0); + } else { + writer.writeString(name); + } + + switch (valueTag) { + case IPP_VALUE.INTEGER: + writer.writeUInt16BE(4); + writer.writeUInt32BE(value); + break; + case IPP_VALUE.BOOLEAN: + writer.writeUInt16BE(1); + writer.writeUInt8(value ? 1 : 0); + break; + case IPP_VALUE.KEYWORD: + case IPP_VALUE.URI: + case IPP_VALUE.CHARSET: + case IPP_VALUE.NATURAL_LANGUAGE: + writer.writeString(value, "ascii"); + break; + default: + throw new Error(`Unsupported IPP value tag: 0x${valueTag.toString(16)}`); + } +} + +function writeAttributeSet(writer, valueTag, name, values) { + const valueList = Array.isArray(values) ? values : [values]; + valueList.forEach((value, index) => { + writeAttribute(writer, valueTag, name, value, index > 0); + }); +} + +function writeMessageHeader(writer, operationId, requestId = 1) { + writer.writeUInt16BE(IPP_VERSION); + writer.writeUInt16BE(operationId); + writer.writeUInt32BE(requestId); +} + +function writeStandardOperationAttributes(writer, printerUri, extras = {}) { + writer.writeUInt8(IPP_TAG.OPERATION); + writeAttribute(writer, IPP_VALUE.CHARSET, "attributes-charset", "utf-8"); + writeAttribute( + writer, + IPP_VALUE.NATURAL_LANGUAGE, + "attributes-natural-language", + "en-us", + ); + writeAttribute(writer, IPP_VALUE.URI, "printer-uri", printerUri); + + for (const [name, spec] of Object.entries(extras)) { + writeAttributeSet(writer, spec.tag, name, spec.value); + } +} + +export function buildCreatePrinterSubscriptionRequest( + printerUri, + { leaseDuration = 86400 } = {}, +) { + const writer = new IppBufferWriter(); + writeMessageHeader(writer, IPP_OPERATIONS.CREATE_PRINTER_SUBSCRIPTIONS); + writeStandardOperationAttributes(writer, printerUri, { + "requested-attributes": { + tag: IPP_VALUE.KEYWORD, + value: ["notify-subscription-id"], + }, + }); + + writer.writeUInt8(IPP_TAG.SUBSCRIPTION); + writeAttribute(writer, IPP_VALUE.KEYWORD, "notify-pull-method", "ippget"); + writeAttributeSet(writer, IPP_VALUE.KEYWORD, "notify-events", [ + "printer-state-changed", + "job-state-changed", + ]); + writeAttribute( + writer, + IPP_VALUE.INTEGER, + "notify-lease-duration", + leaseDuration, + ); + writer.writeUInt8(IPP_TAG.END); + return writer.toBuffer(); +} + +export function buildGetNotificationsRequest( + printerUri, + subscriptionId, + sequenceNumber, + wait, +) { + const writer = new IppBufferWriter(); + writeMessageHeader(writer, IPP_OPERATIONS.GET_NOTIFICATIONS); + + const extras = { + "notify-subscription-ids": { + tag: IPP_VALUE.INTEGER, + value: [subscriptionId], + }, + "notify-sequence-numbers": { + tag: IPP_VALUE.INTEGER, + value: [sequenceNumber], + }, + }; + + if (wait != null) { + extras["notify-wait"] = { + tag: IPP_VALUE.BOOLEAN, + value: wait, + }; + } + + writeStandardOperationAttributes(writer, printerUri, extras); + writer.writeUInt8(IPP_TAG.END); + return writer.toBuffer(); +} + +export function buildCancelSubscriptionRequest(printerUri, subscriptionId) { + const writer = new IppBufferWriter(); + writeMessageHeader(writer, IPP_OPERATIONS.CANCEL_SUBSCRIPTION); + writeStandardOperationAttributes(writer, printerUri, { + "notify-subscription-id": { + tag: IPP_VALUE.INTEGER, + value: subscriptionId, + }, + }); + writer.writeUInt8(IPP_TAG.END); + return writer.toBuffer(); +} + +export function parseIppResponse(buffer) { + let position = 0; + + const read1 = () => buffer[position++]; + const read2 = () => { + const value = buffer.readUInt16BE(position); + position += 2; + return value; + }; + const read4 = () => { + const value = buffer.readUInt32BE(position); + position += 4; + return value; + }; + const read = (length, encoding = "utf8") => { + if (length === 0) { + return ""; + } + const value = buffer.toString(encoding, position, position + length); + position += length; + return value; + }; + + const result = { + version: `${read1()}.${read1()}`, + statusCode: STATUS_CODES[read2()] || "unknown", + id: read4(), + }; + + const hasAdditionalValue = () => { + const current = buffer[position]; + return ( + current !== 0x4a && + current !== 0x37 && + current !== 0x03 && + buffer[position + 1] === 0x00 && + buffer[position + 2] === 0x00 + ); + }; + + const readValue = (tag, length) => { + switch (tag) { + case IPP_VALUE.INTEGER: + case 0x23: + return read4(); + case IPP_VALUE.BOOLEAN: + return read1() === 1; + case IPP_VALUE.KEYWORD: + case IPP_VALUE.URI: + case IPP_VALUE.CHARSET: + case IPP_VALUE.NATURAL_LANGUAGE: + case 0x42: + return read(length, "ascii"); + default: + position += length; + return undefined; + } + }; + + const readValues = (tag) => { + const length = read2(); + let value = readValue(tag, length); + if (hasAdditionalValue()) { + value = [value]; + do { + const nextTag = read1(); + read2(); + const nextLength = read2(); + value.push(readValue(nextTag, nextLength)); + } while (hasAdditionalValue()); + } + return value; + }; + + const readAttr = (group) => { + const tag = read1(); + if (tag === 0x7f) { + read4(); + } + const nameLength = read2(); + const name = read(nameLength); + const value = readValues(tag); + + if (group[name] !== undefined) { + if (!Array.isArray(group[name])) { + group[name] = [group[name]]; + } + group[name].push(value); + } else { + group[name] = value; + } + }; + + const readGroup = (tagByte) => { + const tagName = TAG_NAMES[tagByte] || `tag-0x${tagByte.toString(16)}`; + const group = {}; + + while (position < buffer.length && buffer[position] >= 0x0f) { + readAttr(group); + } + + if (result[tagName]) { + if (!Array.isArray(result[tagName])) { + result[tagName] = [result[tagName]]; + } + result[tagName].push(group); + } else { + result[tagName] = group; + } + }; + + while (position < buffer.length) { + const tag = read1(); + if (tag === IPP_TAG.END) { + break; + } + readGroup(tag); + } + + return result; +} + +function buildAuthHeader(auth) { + if (!auth?.username) { + return null; + } + + const credentials = Buffer.from( + `${auth.username}:${auth.password || ""}`, + ).toString("base64"); + return `Basic ${credentials}`; +} + +function sendRawIppRequestOnce( + httpUrl, + body, + { rejectUnauthorized = true, auth = null } = {}, +) { + return new Promise((resolve, reject) => { + const url = new URL(httpUrl); + const transport = url.protocol === "https:" ? https : http; + const headers = { + "Content-Type": "application/ipp", + "Content-Length": body.length, + }; + const authHeader = buildAuthHeader(auth); + if (authHeader) { + headers.Authorization = authHeader; + } + + const request = transport.request( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port || (url.protocol === "https:" ? 443 : 631), + path: `${url.pathname}${url.search}`, + method: "POST", + headers, + ...(url.protocol === "https:" ? { rejectUnauthorized } : {}), + }, + (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + const responseBody = Buffer.concat(chunks); + + if (response.statusCode !== 200) { + let errorMessage = `Received unexpected response status ${response.statusCode} from the printer`; + + if (responseBody.length > 0) { + try { + const parsedResponse = parseIppResponse(responseBody); + errorMessage = + parsedResponse["operation-attributes-tag"]?.[ + "status-message" + ] || errorMessage; + } catch { + const responseText = responseBody.toString("utf8"); + if (responseText.trim()) { + errorMessage = responseText.trim(); + } + } + } + + const error = new Error(errorMessage); + error.statusCode = response.statusCode; + reject(error); + return; + } + + resolve(parseIppResponse(responseBody)); + }); + }, + ); + + request.on("error", reject); + request.write(body); + request.end(); + }); +} + +export async function sendRawIppRequest( + httpUrl, + body, + { rejectUnauthorized = true, allowTlsUpgrade = true, auth = null } = {}, +) { + try { + return await sendRawIppRequestOnce(httpUrl, body, { + rejectUnauthorized, + auth, + }); + } catch (error) { + if ( + allowTlsUpgrade && + error?.statusCode === 426 && + httpUrl.startsWith("http://") + ) { + const httpsUrl = `https://${httpUrl.slice("http://".length)}`; + return sendRawIppRequestOnce(httpsUrl, body, { + rejectUnauthorized: false, + auth, + }); + } + throw error; + } +} + +export function isSuccessfulIppStatus(statusCode) { + return typeof statusCode === "string" && statusCode.startsWith("successful"); +} + +export function extractSubscriptionId(response) { + const groups = [ + response["subscription-attributes-tag"], + response["subscription-object-attributes-tag"], + ] + .filter(Boolean) + .flatMap((group) => (Array.isArray(group) ? group : [group])); + + for (const group of groups) { + const subscriptionId = group?.["notify-subscription-id"]; + if (subscriptionId != null) { + return Array.isArray(subscriptionId) ? subscriptionId[0] : subscriptionId; + } + } + + return null; +} + +export function buildHttpRequestUrl( + host, + port = 631, + protocol = "ipp", + useTls = false, +) { + let hostName = host; + let path = "/"; + + if (host.includes("/")) { + const parts = host.split("/"); + hostName = parts[0]; + path = `/${parts.slice(1).join("/")}`; + } + + if (hostName.includes(":")) { + const [name, embeddedPort] = hostName.split(":"); + hostName = name; + if (!port) { + port = Number(embeddedPort); + } + } + + const scheme = + useTls || protocol === "https" || protocol === "ipps" ? "https" : "http"; + return `${scheme}://${hostName}:${port}${path}`; +} + +export function buildIppPrinterUri(host, port = 631, useTls = false) { + const httpUrl = buildHttpRequestUrl(host, port, "ipp", useTls); + return httpUrl.replace(/^https?:\/\//, "ipp://"); +} + +export function buildCupsServerUrl(host, port = 631, useTls = false) { + const printerUrl = buildHttpRequestUrl(host, port, "ipp", useTls); + const url = new URL(printerUrl); + return `${url.protocol}//${url.host}/`; +} + +export function buildCupsPpdUrl(host, port = 631, useTls = false) { + const printerUrl = buildHttpRequestUrl(host, port, "ipp", useTls); + if (printerUrl.endsWith(".ppd")) { + return printerUrl; + } + return `${printerUrl.replace(/\/$/, "")}.ppd`; +} + +export async function fetchCupsResource( + httpUrl, + { rejectUnauthorized = true, allowTlsUpgrade = true, auth = null } = {}, +) { + const fetchOnce = (url) => + new Promise((resolve, reject) => { + const parsed = new URL(url); + const transport = parsed.protocol === "https:" ? https : http; + const headers = {}; + const authHeader = buildAuthHeader(auth); + if (authHeader) { + headers.Authorization = authHeader; + } + + const request = transport.request( + { + protocol: parsed.protocol, + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === "https:" ? 443 : 631), + path: `${parsed.pathname}${parsed.search}`, + method: "GET", + headers, + ...(parsed.protocol === "https:" ? { rejectUnauthorized } : {}), + }, + (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + const body = Buffer.concat(chunks); + if (response.statusCode !== 200) { + const error = new Error( + body.toString("utf8").trim() || + `Received unexpected response status ${response.statusCode} from CUPS`, + ); + error.statusCode = response.statusCode; + reject(error); + return; + } + resolve(body.toString("utf8")); + }); + }, + ); + + request.on("error", reject); + request.end(); + }); + + try { + return await fetchOnce(httpUrl); + } catch (error) { + if ( + allowTlsUpgrade && + error?.statusCode === 426 && + httpUrl.startsWith("http://") + ) { + const httpsUrl = `https://${httpUrl.slice("http://".length)}`; + return fetchOnce(httpsUrl); + } + throw error; + } +} diff --git a/src/socket/socketclient.js b/src/socket/socketclient.js index d78e6df..4291c71 100644 --- a/src/socket/socketclient.js +++ b/src/socket/socketclient.js @@ -171,6 +171,9 @@ export class SocketClient { callback, ); } + if (data.objectType == "documentSize") { + this.documentPrinterManager.handleDocumentSizeUpdate(data._id, data); + } if (data.objectType == "printer") { this.printerManager.handlePrinterUpdate(data._id, data, callback); }