Tom Butcher e7bf650e82
All checks were successful
farmcontrol/farmcontrol-server/pipeline/head This commit looks good
Enhance document size management in DocumentPrinterClient and related interfaces
- 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.
2026-09-02 20:55:59 +01:00

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