// 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"); logger.level = config.logLevel; export default class ReceiptInterface { constructor(documentPrinterClient) { this.documentPrinterClient = documentPrinterClient; this.host = documentPrinterClient.connection.host; this.name = documentPrinterClient.documentPrinter.name; this.port = documentPrinterClient.connection.port || 9100; this.interface = documentPrinterClient.connection.interface || "epsonReceipt"; this.protocol = documentPrinterClient.connection.protocol; this.isConnected = false; this.receiptPrinter = null; this.statusMonitoringInterval = null; this.lastReportedStatus = null; this.images = new Map(); } buildPrinterUrl() { switch (this.protocol) { case "tcp": return `tcp://${this.host}:${this.port}`; case "system": return `printer:${this.host}`; case "serial": return this.host; default: logger.warn(`Unknown protocol ${this.protocol}, defaulting to tcp.`); return `tcp://${this.host}:${this.port}`; } } async connect() { try { // Determine printer type enum let type; switch (this.interface) { case "epsonReceipt": type = PrinterTypes.EPSON; break; case "starReceipt": type = PrinterTypes.STAR; break; default: type = PrinterTypes.EPSON; logger.warn( `Unknown interface ${this.interface}, defaulting to EPSON`, ); } // Determine interface based on connection type const interfaceStr = this.buildPrinterUrl(); logger.info( `Connecting to receipt printer ${this.name} (${interfaceStr})`, ); // Initialize thermal printer this.receiptPrinter = new ThermalPrinter({ type: type, interface: interfaceStr, options: { timeout: 10000, }, }); // Test connection const isConnected = await this.receiptPrinter.isPrinterConnected(); if (!isConnected) { logger.error("Printer is not connected or not reachable"); return { error: "Printer is not connected or not reachable." }; } this.isConnected = true; logger.info(`Successfully connected to receipt printer ${this.name}`); return true; } catch (error) { logger.error(`Failed to connect to receipt printer ${this.name}:`, error); this.isConnected = false; return { error: "Failed to connect to receipt printer. " + error.message, }; } } async disconnect() { logger.info(`Disconnecting from receipt printer ${this.name}`); await this.stopStatusMonitoring(); this.isConnected = false; this.receiptPrinter = null; return { success: true }; } async initialize() { logger.info(`Initializing receipt printer ${this.name}`); // Thermal printers typically don't need special initialization // but we can test the connection if (this.receiptPrinter) { try { const isConnected = await this.receiptPrinter.isPrinterConnected(); if (!isConnected) { logger.error( `Printer not connected during initialization for receipt printer ${this.name}`, ); return { error: "Printer not connected during initialization" }; } logger.info(`Receipt printer ${this.name} initialized successfully`); } catch (error) { logger.error( `Failed to initialize receipt printer ${this.name}:`, error, ); } } return true; } async print(jobId, 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}`, ); if (!this.isConnected || !this.receiptPrinter) { throw new Error("Printer is not connected"); } try { // Clear the printer buffer to prevent previous print jobs from being included this.receiptPrinter.clear(); const images = this.images.get(jobId); if (!images || (Array.isArray(images) && images.length === 0)) { throw new Error("Image not found"); } const imageArray = Array.isArray(images) ? images : [images]; for (let copy = 0; copy < copies; copy++) { for (const image of imageArray) { const imageBuffer = Buffer.isBuffer(image) ? Buffer.from(image) : Buffer.from(image); await this.receiptPrinter.printImageBuffer(imageBuffer); this.receiptPrinter.cut(); } } // Execute the print job await this.receiptPrinter.execute({ waitForResponse: true }); logger.info( `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, ); return { success: false, error: error.message }; } } async deploy(documentJob, documentTemplate, object, onProgress) { logger.info( `Deploying job ${documentJob._id} to receipt printer ${this.name}`, ); const imageObj = await this.documentPrinterClient.socketClient.renderTemplateJPG( { _id: documentTemplate._id, content: documentTemplate.content, object: object, width: 512, }, "png", onProgress, ); if (!imageObj || !imageObj.images) { throw new Error( imageObj?.error || "Failed to render document template to image", ); } this.images.set(documentJob._id, imageObj.images); 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.trace(`Getting status of receipt printer ${this.name}`); if (!this.isConnected) { logger.error("Printer is not connected or not reachable"); return buildStatusError("Printer is not connected or not reachable."); } 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; } }