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; 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 { return new Promise((resolve, reject) => { this.printer.execute( 'Get-Printer-Attributes', null, (err: Error | null, res: Record) => { if (err) { reject(err); return; } try { const attrs = res['printer-attributes-tag'] as Record || {}; resolve(this.parseCapabilities(attrs)); } catch (parseErr) { reject(parseErr); } } ); }); } /** * Print a document */ public async print(data: Buffer, options?: IPrintOptions): Promise { const msg = this.buildPrintMessage(options); return new Promise((resolve, reject) => { this.printer.execute( 'Print-Job', { ...msg, data }, (err: Error | null, res: Record) => { if (err) { reject(err); return; } try { const jobAttrs = res['job-attributes-tag'] as Record || {}; resolve(this.parseJobInfo(jobAttrs)); } catch (parseErr) { reject(parseErr); } } ); }); } /** * Get all jobs */ public async getJobs(): Promise { 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) => { 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)); } } else if (jobTags && typeof jobTags === 'object') { jobs.push(this.parseJobInfo(jobTags as Record)); } resolve(jobs); } catch (parseErr) { reject(parseErr); } } ); }); } /** * Get specific job info */ public async getJobInfo(jobId: number): Promise { return new Promise((resolve, reject) => { this.printer.execute( 'Get-Job-Attributes', { 'operation-attributes-tag': { 'job-id': jobId, }, }, (err: Error | null, res: Record) => { if (err) { reject(err); return; } try { const jobAttrs = res['job-attributes-tag'] as Record || {}; resolve(this.parseJobInfo(jobAttrs)); } catch (parseErr) { reject(parseErr); } } ); }); } /** * Cancel a job */ public async cancelJob(jobId: number): Promise { return new Promise((resolve, reject) => { this.printer.execute( 'Cancel-Job', { 'operation-attributes-tag': { 'job-id': jobId, }, }, (err: Error | null, _res: Record) => { if (err) { reject(err); return; } resolve(); } ); }); } /** * Check if printer is available */ public async checkAvailability(): Promise { try { await this.getAttributes(); return true; } catch { return false; } } /** * Build IPP print message from options */ private buildPrintMessage(options?: IPrintOptions): Record { const operationAttrs: Record = { 'requesting-user-name': 'devicemanager', 'job-name': options?.jobName ?? 'Print Job', 'document-format': 'application/octet-stream', }; const jobAttrs: Record = {}; 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 = { 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 = { '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): 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): 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 = { 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), }; } }