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.
387 lines
11 KiB
JavaScript
387 lines
11 KiB
JavaScript
// 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;
|
|
}
|
|
}
|