initial
This commit is contained in:
329
ts/printer/printer.classes.ippprotocol.ts
Normal file
329
ts/printer/printer.classes.ippprotocol.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type {
|
||||
IPrinterCapabilities,
|
||||
IPrintOptions,
|
||||
IPrintJob,
|
||||
} from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* IPP protocol wrapper using the ipp npm package
|
||||
*/
|
||||
export class IppProtocol {
|
||||
private printerUrl: string;
|
||||
private printer: ReturnType<typeof plugins.ipp.Printer>;
|
||||
|
||||
constructor(address: string, port: number, path: string = '/ipp/print') {
|
||||
this.printerUrl = `ipp://${address}:${port}${path}`;
|
||||
this.printer = plugins.ipp.Printer(this.printerUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get printer attributes/capabilities
|
||||
*/
|
||||
public async getAttributes(): Promise<IPrinterCapabilities> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.printer.execute(
|
||||
'Get-Printer-Attributes',
|
||||
null,
|
||||
(err: Error | null, res: Record<string, unknown>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const attrs = res['printer-attributes-tag'] as Record<string, unknown> || {};
|
||||
resolve(this.parseCapabilities(attrs));
|
||||
} catch (parseErr) {
|
||||
reject(parseErr);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a document
|
||||
*/
|
||||
public async print(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
||||
const msg = this.buildPrintMessage(options);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.printer.execute(
|
||||
'Print-Job',
|
||||
{ ...msg, data },
|
||||
(err: Error | null, res: Record<string, unknown>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const jobAttrs = res['job-attributes-tag'] as Record<string, unknown> || {};
|
||||
resolve(this.parseJobInfo(jobAttrs));
|
||||
} catch (parseErr) {
|
||||
reject(parseErr);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all jobs
|
||||
*/
|
||||
public async getJobs(): Promise<IPrintJob[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.printer.execute(
|
||||
'Get-Jobs',
|
||||
{
|
||||
'operation-attributes-tag': {
|
||||
'requesting-user-name': 'devicemanager',
|
||||
'which-jobs': 'not-completed',
|
||||
},
|
||||
},
|
||||
(err: Error | null, res: Record<string, unknown>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const jobs: IPrintJob[] = [];
|
||||
const jobTags = res['job-attributes-tag'];
|
||||
|
||||
if (Array.isArray(jobTags)) {
|
||||
for (const jobAttrs of jobTags) {
|
||||
jobs.push(this.parseJobInfo(jobAttrs as Record<string, unknown>));
|
||||
}
|
||||
} else if (jobTags && typeof jobTags === 'object') {
|
||||
jobs.push(this.parseJobInfo(jobTags as Record<string, unknown>));
|
||||
}
|
||||
|
||||
resolve(jobs);
|
||||
} catch (parseErr) {
|
||||
reject(parseErr);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific job info
|
||||
*/
|
||||
public async getJobInfo(jobId: number): Promise<IPrintJob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.printer.execute(
|
||||
'Get-Job-Attributes',
|
||||
{
|
||||
'operation-attributes-tag': {
|
||||
'job-id': jobId,
|
||||
},
|
||||
},
|
||||
(err: Error | null, res: Record<string, unknown>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const jobAttrs = res['job-attributes-tag'] as Record<string, unknown> || {};
|
||||
resolve(this.parseJobInfo(jobAttrs));
|
||||
} catch (parseErr) {
|
||||
reject(parseErr);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a job
|
||||
*/
|
||||
public async cancelJob(jobId: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.printer.execute(
|
||||
'Cancel-Job',
|
||||
{
|
||||
'operation-attributes-tag': {
|
||||
'job-id': jobId,
|
||||
},
|
||||
},
|
||||
(err: Error | null, _res: Record<string, unknown>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if printer is available
|
||||
*/
|
||||
public async checkAvailability(): Promise<boolean> {
|
||||
try {
|
||||
await this.getAttributes();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build IPP print message from options
|
||||
*/
|
||||
private buildPrintMessage(options?: IPrintOptions): Record<string, unknown> {
|
||||
const operationAttrs: Record<string, unknown> = {
|
||||
'requesting-user-name': 'devicemanager',
|
||||
'job-name': options?.jobName ?? 'Print Job',
|
||||
'document-format': 'application/octet-stream',
|
||||
};
|
||||
|
||||
const jobAttrs: Record<string, unknown> = {};
|
||||
|
||||
if (options?.copies && options.copies > 1) {
|
||||
jobAttrs['copies'] = options.copies;
|
||||
}
|
||||
|
||||
if (options?.mediaSize) {
|
||||
jobAttrs['media'] = options.mediaSize;
|
||||
}
|
||||
|
||||
if (options?.mediaType) {
|
||||
jobAttrs['media-type'] = options.mediaType;
|
||||
}
|
||||
|
||||
if (options?.sides) {
|
||||
jobAttrs['sides'] = options.sides;
|
||||
}
|
||||
|
||||
if (options?.quality) {
|
||||
const qualityMap: Record<string, number> = {
|
||||
draft: 3,
|
||||
normal: 4,
|
||||
high: 5,
|
||||
};
|
||||
jobAttrs['print-quality'] = qualityMap[options.quality] ?? 4;
|
||||
}
|
||||
|
||||
if (options?.colorMode) {
|
||||
jobAttrs['print-color-mode'] = options.colorMode;
|
||||
}
|
||||
|
||||
const msg: Record<string, unknown> = {
|
||||
'operation-attributes-tag': operationAttrs,
|
||||
};
|
||||
|
||||
if (Object.keys(jobAttrs).length > 0) {
|
||||
msg['job-attributes-tag'] = jobAttrs;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse printer capabilities from attributes
|
||||
*/
|
||||
private parseCapabilities(attrs: Record<string, unknown>): IPrinterCapabilities {
|
||||
const getArray = (key: string): string[] => {
|
||||
const value = attrs[key];
|
||||
if (Array.isArray(value)) return value.map(String);
|
||||
if (value !== undefined) return [String(value)];
|
||||
return [];
|
||||
};
|
||||
|
||||
const getNumber = (key: string, defaultVal: number): number => {
|
||||
const value = attrs[key];
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') return parseInt(value) || defaultVal;
|
||||
return defaultVal;
|
||||
};
|
||||
|
||||
const getBool = (key: string, defaultVal: boolean): boolean => {
|
||||
const value = attrs[key];
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true' || value === 1) return true;
|
||||
if (value === 'false' || value === 0) return false;
|
||||
return defaultVal;
|
||||
};
|
||||
|
||||
// Parse resolutions
|
||||
const resolutions: number[] = [];
|
||||
const resSupported = attrs['printer-resolution-supported'];
|
||||
if (Array.isArray(resSupported)) {
|
||||
for (const res of resSupported) {
|
||||
if (typeof res === 'object' && res !== null && 'x' in res) {
|
||||
resolutions.push((res as { x: number }).x);
|
||||
} else if (typeof res === 'number') {
|
||||
resolutions.push(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (resolutions.length === 0) {
|
||||
resolutions.push(300, 600);
|
||||
}
|
||||
|
||||
return {
|
||||
colorSupported: getBool('color-supported', false),
|
||||
duplexSupported:
|
||||
getArray('sides-supported').some((s) =>
|
||||
s.includes('two-sided')
|
||||
),
|
||||
mediaSizes: getArray('media-supported'),
|
||||
mediaTypes: getArray('media-type-supported'),
|
||||
resolutions: [...new Set(resolutions)],
|
||||
maxCopies: getNumber('copies-supported', 99),
|
||||
sidesSupported: getArray('sides-supported'),
|
||||
qualitySupported: getArray('print-quality-supported').map(String),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse job info from attributes
|
||||
*/
|
||||
private parseJobInfo(attrs: Record<string, unknown>): IPrintJob {
|
||||
const getString = (key: string, defaultVal: string): string => {
|
||||
const value = attrs[key];
|
||||
if (typeof value === 'string') return value;
|
||||
if (value !== undefined) return String(value);
|
||||
return defaultVal;
|
||||
};
|
||||
|
||||
const getNumber = (key: string, defaultVal: number): number => {
|
||||
const value = attrs[key];
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') return parseInt(value) || defaultVal;
|
||||
return defaultVal;
|
||||
};
|
||||
|
||||
// Map IPP job state to our state
|
||||
const ippState = getNumber('job-state', 3);
|
||||
const stateMap: Record<number, IPrintJob['state']> = {
|
||||
3: 'pending', // pending
|
||||
4: 'pending', // pending-held
|
||||
5: 'processing', // processing
|
||||
6: 'processing', // processing-stopped
|
||||
7: 'canceled', // canceled
|
||||
8: 'aborted', // aborted
|
||||
9: 'completed', // completed
|
||||
};
|
||||
|
||||
const createdTime = attrs['time-at-creation'];
|
||||
const completedTime = attrs['time-at-completed'];
|
||||
|
||||
return {
|
||||
id: getNumber('job-id', 0),
|
||||
name: getString('job-name', 'Unknown Job'),
|
||||
state: stateMap[ippState] ?? 'pending',
|
||||
stateReason: getString('job-state-reasons', undefined),
|
||||
createdAt: createdTime ? new Date(createdTime as number * 1000) : new Date(),
|
||||
completedAt: completedTime ? new Date(completedTime as number * 1000) : undefined,
|
||||
pagesPrinted: getNumber('job-media-sheets-completed', undefined),
|
||||
pagesTotal: getNumber('job-media-sheets', undefined),
|
||||
};
|
||||
}
|
||||
}
|
||||
255
ts/printer/printer.classes.printer.ts
Normal file
255
ts/printer/printer.classes.printer.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { Device } from '../abstract/device.abstract.js';
|
||||
import { IppProtocol } from './printer.classes.ippprotocol.js';
|
||||
import type {
|
||||
IPrinterInfo,
|
||||
IPrinterCapabilities,
|
||||
IPrintOptions,
|
||||
IPrintJob,
|
||||
IRetryOptions,
|
||||
} from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Printer class for IPP network printers
|
||||
*/
|
||||
export class Printer extends Device {
|
||||
public readonly uri: string;
|
||||
public supportsColor: boolean = false;
|
||||
public supportsDuplex: boolean = false;
|
||||
public supportedMediaTypes: string[] = [];
|
||||
public supportedMediaSizes: string[] = [];
|
||||
public maxCopies: number = 99;
|
||||
|
||||
private ippClient: IppProtocol | null = null;
|
||||
private ippPath: string;
|
||||
|
||||
constructor(
|
||||
info: IPrinterInfo,
|
||||
options?: {
|
||||
ippPath?: string;
|
||||
retryOptions?: IRetryOptions;
|
||||
}
|
||||
) {
|
||||
super(info, options?.retryOptions);
|
||||
this.uri = info.uri;
|
||||
this.supportsColor = info.supportsColor;
|
||||
this.supportsDuplex = info.supportsDuplex;
|
||||
this.supportedMediaTypes = info.supportedMediaTypes;
|
||||
this.supportedMediaSizes = info.supportedMediaSizes;
|
||||
this.maxCopies = info.maxCopies;
|
||||
this.ippPath = options?.ippPath ?? '/ipp/print';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Printer from discovery info
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
discoveredDevice: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
txtRecords: Record<string, string>;
|
||||
},
|
||||
retryOptions?: IRetryOptions
|
||||
): Printer {
|
||||
// Parse capabilities from TXT records
|
||||
const txtRecords = discoveredDevice.txtRecords;
|
||||
|
||||
// Get IPP path from TXT records
|
||||
const rp = txtRecords['rp'] || 'ipp/print';
|
||||
const ippPath = rp.startsWith('/') ? rp : `/${rp}`;
|
||||
|
||||
// Parse color support
|
||||
const colorSupported =
|
||||
txtRecords['Color'] === 'T' ||
|
||||
txtRecords['color'] === 'true' ||
|
||||
txtRecords['URF']?.includes('W8') ||
|
||||
false;
|
||||
|
||||
// Parse duplex support
|
||||
const duplexSupported =
|
||||
txtRecords['Duplex'] === 'T' ||
|
||||
txtRecords['duplex'] === 'true' ||
|
||||
txtRecords['URF']?.includes('DM') ||
|
||||
false;
|
||||
|
||||
// Build printer URI
|
||||
const isSecure = txtRecords['TLS'] === '1' || discoveredDevice.port === 443;
|
||||
const protocol = isSecure ? 'ipps' : 'ipp';
|
||||
const uri = `${protocol}://${discoveredDevice.address}:${discoveredDevice.port}${ippPath}`;
|
||||
|
||||
const info: IPrinterInfo = {
|
||||
id: discoveredDevice.id,
|
||||
name: discoveredDevice.name,
|
||||
type: 'printer',
|
||||
address: discoveredDevice.address,
|
||||
port: discoveredDevice.port,
|
||||
status: 'online',
|
||||
uri: uri,
|
||||
supportsColor: colorSupported,
|
||||
supportsDuplex: duplexSupported,
|
||||
supportedMediaTypes: [],
|
||||
supportedMediaSizes: [],
|
||||
maxCopies: 99,
|
||||
manufacturer: txtRecords['usb_MFG'] || txtRecords['mfg'],
|
||||
model: txtRecords['usb_MDL'] || txtRecords['mdl'] || txtRecords['ty'],
|
||||
};
|
||||
|
||||
return new Printer(info, { ippPath, retryOptions });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get printer info
|
||||
*/
|
||||
public getPrinterInfo(): IPrinterInfo {
|
||||
return {
|
||||
...this.getInfo(),
|
||||
type: 'printer',
|
||||
uri: this.uri,
|
||||
supportsColor: this.supportsColor,
|
||||
supportsDuplex: this.supportsDuplex,
|
||||
supportedMediaTypes: this.supportedMediaTypes,
|
||||
supportedMediaSizes: this.supportedMediaSizes,
|
||||
maxCopies: this.maxCopies,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get printer capabilities
|
||||
*/
|
||||
public async getCapabilities(): Promise<IPrinterCapabilities> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
const caps = await this.withRetry(() => this.ippClient!.getAttributes());
|
||||
|
||||
// Update local properties
|
||||
this.supportsColor = caps.colorSupported;
|
||||
this.supportsDuplex = caps.duplexSupported;
|
||||
this.supportedMediaSizes = caps.mediaSizes;
|
||||
this.supportedMediaTypes = caps.mediaTypes;
|
||||
this.maxCopies = caps.maxCopies;
|
||||
|
||||
return caps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a document
|
||||
*/
|
||||
public async print(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
this.setStatus('busy');
|
||||
this.emit('print:started', options);
|
||||
|
||||
try {
|
||||
const job = await this.withRetry(() => this.ippClient!.print(data, options));
|
||||
this.setStatus('online');
|
||||
this.emit('print:submitted', job);
|
||||
return job;
|
||||
} catch (error) {
|
||||
this.setStatus('online');
|
||||
this.emit('print:error', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all print jobs
|
||||
*/
|
||||
public async getJobs(): Promise<IPrintJob[]> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
return this.withRetry(() => this.ippClient!.getJobs());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific job info
|
||||
*/
|
||||
public async getJobInfo(jobId: number): Promise<IPrintJob> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
return this.withRetry(() => this.ippClient!.getJobInfo(jobId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a print job
|
||||
*/
|
||||
public async cancelJob(jobId: number): Promise<void> {
|
||||
if (!this.isConnected) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
await this.withRetry(() => this.ippClient!.cancelJob(jobId));
|
||||
this.emit('print:canceled', jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the printer
|
||||
*/
|
||||
protected async doConnect(): Promise<void> {
|
||||
this.ippClient = new IppProtocol(this.address, this.port, this.ippPath);
|
||||
|
||||
// Test connection by checking availability
|
||||
const available = await this.ippClient.checkAvailability();
|
||||
if (!available) {
|
||||
throw new Error('Printer not available');
|
||||
}
|
||||
|
||||
// Fetch capabilities to populate local properties
|
||||
await this.getCapabilities();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the printer
|
||||
*/
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
this.ippClient = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh printer status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
try {
|
||||
if (this.ippClient) {
|
||||
const available = await this.ippClient.checkAvailability();
|
||||
this.setStatus(available ? 'online' : 'offline');
|
||||
} else {
|
||||
this.setStatus('offline');
|
||||
}
|
||||
} catch (error) {
|
||||
this.setStatus('error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { IppProtocol };
|
||||
Reference in New Issue
Block a user