Enhance document size management in DocumentPrinterClient and related interfaces
All checks were successful
farmcontrol/farmcontrol-server/pipeline/head This commit looks good

- Introduced a `getDocumentSizeId` utility function to streamline document size ID retrieval.
- Updated `DocumentPrinterClient` to manage subscriptions for current document sizes, ensuring accurate updates and handling of document size changes.
- Enhanced `DocumentPrinterManager` to handle document size updates and propagate changes to connected clients.
- Improved `CupsInterface` and `ReceiptInterface` to support document size resolution and default media application based on current document sizes.
- Added detailed logging for document size updates and error handling to improve debugging and user feedback.
This commit is contained in:
Tom Butcher 2026-09-02 20:55:59 +01:00
parent d04e722366
commit e7bf650e82
7 changed files with 2208 additions and 67 deletions

View File

@ -11,6 +11,18 @@ const config = loadConfig();
const logger = log4js.getLogger("Document Printer Client"); const logger = log4js.getLogger("Document Printer Client");
logger.level = config.logLevel; 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 { export class DocumentPrinterClient {
constructor( constructor(
documentPrinter = { documentPrinter = {
@ -29,16 +41,18 @@ export class DocumentPrinterClient {
this.socketClient = documentPrinterManager.socketClient; this.socketClient = documentPrinterManager.socketClient;
this.interface = documentPrinter.connection.interface || "cups"; // cups, receipt, or os this.interface = documentPrinter.connection.interface || "cups"; // cups, receipt, or os
this.state = { type: this.active == true ? "offline" : "inactive" }; this.state = { type: this.active == true ? "offline" : "inactive" };
this.paperState = documentPrinter.paperState || { type: "unknown" };
this.isOnline = documentPrinter.online || false; this.isOnline = documentPrinter.online || false;
this.shouldReconnect = true; this.shouldReconnect = true;
this.isConnecting = false; this.isConnecting = false;
this.reconnectTimeout = null; this.reconnectTimeout = null;
this.isProcessingQueue = false; this.isProcessingQueue = false;
this.eventUpdateInterval = null; this.subscribedDocumentSizeId = null;
this.initializeInterface(); this.initializeInterface();
this.registerEventHandlers(); this.registerEventHandlers();
this.subscribeToActions(); this.subscribeToActions();
this.subscribeToObjectUpdates(); this.subscribeToObjectUpdates();
this.syncCurrentDocumentSizeSubscription();
} }
initializeInterface() { initializeInterface() {
@ -68,6 +82,12 @@ export class DocumentPrinterClient {
logger.debug(`Updating document printer ${this.id} with data...`); logger.debug(`Updating document printer ${this.id} with data...`);
sendIPC("setDocumentPrinter", { _id: this.id, ...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 // Check for connection changes before updating
if (data?.connection) { if (data?.connection) {
@ -83,9 +103,20 @@ export class DocumentPrinterClient {
newConnection.interface != null && newConnection.interface != null &&
newConnection.interface !== oldConnection.interface; newConnection.interface !== oldConnection.interface;
const portChanged = newConnection?.port !== oldConnection?.port; 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 = const connectionChanged =
hostChanged || interfaceChanged || portChanged || protocolChanged; hostChanged ||
interfaceChanged ||
portChanged ||
protocolChanged ||
usernameChanged ||
passwordChanged;
logger.debug(`Connection changed: ${connectionChanged}`); logger.debug(`Connection changed: ${connectionChanged}`);
logger.debug(`Host changed: ${hostChanged}`); logger.debug(`Host changed: ${hostChanged}`);
@ -107,11 +138,34 @@ export class DocumentPrinterClient {
await this.printerInterface.disconnect(); await this.printerInterface.disconnect();
this.initializeInterface(); this.initializeInterface();
await this.reconnect(); await this.reconnect();
} else if (data?.connection) {
this.connection = { ...oldConnection, ...newConnection };
} }
} }
this.documentPrinter = { ...this.documentPrinter, ...data }; 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 (Object.hasOwn(data || {}, "active") && data.active != this.active) {
if (data.active == true) { if (data.active == true) {
await this.setActive(); 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() { registerEventHandlers() {
// Register event handlers for document printer notifications // Register event handlers for document printer notifications
// This can be extended as needed // 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) { statesEqual(a, b) {
if (!a && !b) { if (!a && !b) {
return true; 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) { scheduleReconnect(delay = 30000) {
if (!this.shouldReconnect || this.active === false) { if (!this.shouldReconnect || this.active === false) {
return; return;
@ -187,9 +365,7 @@ export class DocumentPrinterClient {
clearTimeout(this.reconnectTimeout); clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null; this.reconnectTimeout = null;
// Always stop the event update interval when connecting await this.stopStatusMonitoring();
clearInterval(this.eventUpdateInterval);
this.eventUpdateInterval = null;
this.state = { type: "connecting", message: null }; this.state = { type: "connecting", message: null };
this.isOnline = false; this.isOnline = false;
await this.updateDocumentPrinterState(); await this.updateDocumentPrinterState();
@ -241,6 +417,8 @@ export class DocumentPrinterClient {
return false; return false;
} }
this.syncCurrentDocumentSizeSubscription();
clearTimeout(this.reconnectTimeout); clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null; this.reconnectTimeout = null;
@ -295,10 +473,7 @@ export class DocumentPrinterClient {
} }
this.state = { type: "standby", message: null }; this.state = { type: "standby", message: null };
await this.updateDocumentPrinterState(); await this.updateDocumentPrinterState();
this.eventUpdateInterval = setInterval( await this.startStatusMonitoring();
this.handleEventUpdate.bind(this),
3000,
);
await stopWaiting(); await stopWaiting();
return true; return true;
} }
@ -309,16 +484,30 @@ export class DocumentPrinterClient {
} }
} }
async handleEventUpdate() { async startStatusMonitoring() {
if (this.printerInterface && this.printerInterface.retrieveStatus) { if (this.printerInterface?.startStatusMonitoring) {
try { await this.printerInterface.startStatusMonitoring();
//await this.printerInterface.retrieveStatus(); }
} catch (error) { }
async stopStatusMonitoring() {
if (this.printerInterface?.stopStatusMonitoring) {
await this.printerInterface.stopStatusMonitoring();
}
}
async handlePrinterStatusUpdate(status) {
if (!status || status.error) {
if (status?.error) {
logger.error( logger.error(
`Error retrieving status for document printer ${this.documentPrinter.name}:`, `Error retrieving status for document printer ${this.documentPrinter.name}: ${status.error}`,
error,
); );
} }
return;
}
if (this.applyPrinterStatus(status)) {
await this.updateDocumentPrinterState();
} }
} }
@ -326,6 +515,7 @@ export class DocumentPrinterClient {
const updateData = { const updateData = {
online: this.isOnline, online: this.isOnline,
state: this.state, state: this.state,
paperState: this.paperState,
connectedAt: this.connectedAt ?? null, connectedAt: this.connectedAt ?? null,
}; };
@ -337,6 +527,10 @@ export class DocumentPrinterClient {
if ( if (
this.documentPrinter?.online === updateData.online && this.documentPrinter?.online === updateData.online &&
this.statesEqual(this.documentPrinter?.state, updateData.state) && this.statesEqual(this.documentPrinter?.state, updateData.state) &&
this.paperStatesEqual(
this.documentPrinter?.paperState,
updateData.paperState,
) &&
connectedAtUnchanged connectedAtUnchanged
) { ) {
return; return;
@ -590,7 +784,10 @@ export class DocumentPrinterClient {
); );
await this.updateJobState(jobId, { type: "printing" }); 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( logger.info(
`Successfully printed job ${jobId} (${quantity} ${quantity === 1 ? "copy" : "copies"}) for ${this.documentPrinter.name}`, `Successfully printed job ${jobId} (${quantity} ${quantity === 1 ? "copy" : "copies"}) for ${this.documentPrinter.name}`,
); );
@ -630,14 +827,14 @@ export class DocumentPrinterClient {
this.shouldReconnect = false; this.shouldReconnect = false;
clearTimeout(this.reconnectTimeout); clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null; this.reconnectTimeout = null;
// Always stop the event update interval when disconnecting this.unsubscribeFromCurrentDocumentSizeUpdates();
clearInterval(this.eventUpdateInterval); await this.stopStatusMonitoring();
this.eventUpdateInterval = null;
if (this.printerInterface && this.printerInterface.disconnect) { if (this.printerInterface && this.printerInterface.disconnect) {
await this.printerInterface.disconnect(); await this.printerInterface.disconnect();
} }
this.isOnline = false; this.isOnline = false;
this.state = { type: this.active == false ? "inactive" : "offline" }; this.state = { type: this.active == false ? "inactive" : "offline" };
this.paperState = { type: "unknown" };
this.isProcessingQueue = false; this.isProcessingQueue = false;
this.queue = []; // Clear queue on disconnect this.queue = []; // Clear queue on disconnect
this.jobQuantities.clear(); this.jobQuantities.clear();
@ -660,6 +857,7 @@ export class DocumentPrinterClient {
this.documentPrinter.active = true; this.documentPrinter.active = true;
this.shouldReconnect = true; this.shouldReconnect = true;
this.isOnline = false; this.isOnline = false;
this.syncCurrentDocumentSizeSubscription();
await this.reconnect(); await this.reconnect();
} }
} }

View File

@ -22,6 +22,7 @@ export class DocumentPrinterManager {
this.documentPrinters = await this.socketClient.listObjects({ this.documentPrinters = await this.socketClient.listObjects({
objectType: "documentPrinter", objectType: "documentPrinter",
filter: { host: this.socketClient.id }, filter: { host: this.socketClient.id },
populate: ["currentDocumentSize"],
}); });
sendIPC("setDocumentPrinters", this.documentPrinters); 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) { getDocumentPrinterClient(documentPrinterId) {
return this.documentPrinterClients.get(documentPrinterId); return this.documentPrinterClients.get(documentPrinterId);
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,17 @@
// receiptinterface.js - Thermal receipt printer interface implementation // receiptinterface.js - Thermal receipt printer interface implementation
import net from "net";
import log4js from "log4js"; import log4js from "log4js";
import { loadConfig } from "../../config.js"; import { loadConfig } from "../../config.js";
import { ThermalPrinter, PrinterTypes } from "node-thermal-printer"; 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 config = loadConfig();
const logger = log4js.getLogger("Receipt Printer Interface"); const logger = log4js.getLogger("Receipt Printer Interface");
@ -18,7 +28,8 @@ export default class ReceiptInterface {
this.protocol = documentPrinterClient.connection.protocol; this.protocol = documentPrinterClient.connection.protocol;
this.isConnected = false; this.isConnected = false;
this.receiptPrinter = null; this.receiptPrinter = null;
this.retrieveStatusInterval = null; this.statusMonitoringInterval = null;
this.lastReportedStatus = null;
this.images = new Map(); this.images = new Map();
} }
@ -50,7 +61,7 @@ export default class ReceiptInterface {
default: default:
type = PrinterTypes.EPSON; type = PrinterTypes.EPSON;
logger.warn( 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(); const interfaceStr = this.buildPrinterUrl();
logger.info( logger.info(
`Connecting to receipt printer ${this.name} (${interfaceStr})` `Connecting to receipt printer ${this.name} (${interfaceStr})`,
); );
// Initialize thermal printer // Initialize thermal printer
@ -91,6 +102,7 @@ export default class ReceiptInterface {
async disconnect() { async disconnect() {
logger.info(`Disconnecting from receipt printer ${this.name}`); logger.info(`Disconnecting from receipt printer ${this.name}`);
await this.stopStatusMonitoring();
this.isConnected = false; this.isConnected = false;
this.receiptPrinter = null; this.receiptPrinter = null;
return { success: true }; return { success: true };
@ -105,7 +117,7 @@ export default class ReceiptInterface {
const isConnected = await this.receiptPrinter.isPrinterConnected(); const isConnected = await this.receiptPrinter.isPrinterConnected();
if (!isConnected) { if (!isConnected) {
logger.error( 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" }; return { error: "Printer not connected during initialization" };
} }
@ -113,7 +125,7 @@ export default class ReceiptInterface {
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to initialize receipt printer ${this.name}:`, `Failed to initialize receipt printer ${this.name}:`,
error error,
); );
} }
} }
@ -123,7 +135,7 @@ export default class ReceiptInterface {
async print(jobId, quantity = 1) { async print(jobId, quantity = 1) {
const copies = Math.max(1, Math.floor(Number(quantity) || 1)); const copies = Math.max(1, Math.floor(Number(quantity) || 1));
logger.info( 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) { if (!this.isConnected || !this.receiptPrinter) {
@ -155,14 +167,14 @@ export default class ReceiptInterface {
await this.receiptPrinter.execute({ waitForResponse: true }); await this.receiptPrinter.execute({ waitForResponse: true });
logger.info( 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 }; return { success: true, copies };
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to print job ${jobId} to receipt printer ${this.name}:`, `Failed to print job ${jobId} to receipt printer ${this.name}:`,
error error,
); );
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
@ -170,7 +182,7 @@ export default class ReceiptInterface {
async deploy(documentJob, documentTemplate, object, onProgress) { async deploy(documentJob, documentTemplate, object, onProgress) {
logger.info( logger.info(
`Deploying job ${documentJob._id} to receipt printer ${this.name}` `Deploying job ${documentJob._id} to receipt printer ${this.name}`,
); );
const imageObj = const imageObj =
@ -187,7 +199,7 @@ export default class ReceiptInterface {
if (!imageObj || !imageObj.images) { if (!imageObj || !imageObj.images) {
throw new Error( 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 }; 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() { async retrieveStatus() {
logger.debug(`Getting status of receipt printer ${this.name}`); logger.trace(`Getting status of receipt printer ${this.name}`);
if (this.isOnline == false) {
if (!this.isConnected) {
logger.error("Printer is not connected or not reachable"); logger.error("Printer is not connected or not reachable");
return { error: "Printer is not connected or not reachable." }; return buildStatusError("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.`); if (this.protocol && this.protocol !== "tcp") {
return true; 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;
} }
} }

View File

@ -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,
});
}

View File

@ -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;
}
}

View File

@ -171,6 +171,9 @@ export class SocketClient {
callback, callback,
); );
} }
if (data.objectType == "documentSize") {
this.documentPrinterManager.handleDocumentSizeUpdate(data._id, data);
}
if (data.objectType == "printer") { if (data.objectType == "printer") {
this.printerManager.handlePrinterUpdate(data._id, data, callback); this.printerManager.handlePrinterUpdate(data._id, data, callback);
} }