This commit is contained in:
2026-01-09 07:14:39 +00:00
parent 95da37590c
commit 05e1f94c79
22 changed files with 6549 additions and 10 deletions

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