@ecobridge.xyz/devicemanager 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,329 +1,1252 @@
1
- import * as plugins from '../plugins.js';
2
- import type {
3
- IPrinterCapabilities,
4
- IPrintOptions,
5
- IPrintJob,
6
- } from '../interfaces/index.js';
7
-
8
1
  /**
9
- * IPP protocol wrapper using the ipp npm package
2
+ * Clean IPP Protocol Implementation
3
+ * RFC 8010 (Encoding) and RFC 8011 (Model)
4
+ *
5
+ * This is a from-scratch implementation with proper capability detection,
6
+ * document format negotiation, and clean attribute parsing.
10
7
  */
11
- export class IppProtocol {
12
- private printerUrl: string;
13
- private printer: ReturnType<typeof plugins.ipp.Printer>;
14
8
 
15
- constructor(address: string, port: number, path: string = '/ipp/print') {
16
- this.printerUrl = `ipp://${address}:${port}${path}`;
17
- this.printer = plugins.ipp.Printer(this.printerUrl);
9
+ // ============================================================================
10
+ // Constants
11
+ // ============================================================================
12
+
13
+ /** IPP Version */
14
+ const IPP_VERSION = {
15
+ V1_1: 0x0101,
16
+ V2_0: 0x0200,
17
+ } as const;
18
+
19
+ /** IPP Operations */
20
+ const IPP_OPERATION = {
21
+ PRINT_JOB: 0x0002,
22
+ VALIDATE_JOB: 0x0004,
23
+ CANCEL_JOB: 0x0008,
24
+ GET_JOB_ATTRIBUTES: 0x0009,
25
+ GET_JOBS: 0x000a,
26
+ GET_PRINTER_ATTRIBUTES: 0x000b,
27
+ } as const;
28
+
29
+ /** IPP Status Codes */
30
+ const IPP_STATUS = {
31
+ // Successful
32
+ SUCCESSFUL_OK: 0x0000,
33
+ SUCCESSFUL_OK_IGNORED_ATTRIBUTES: 0x0001,
34
+ SUCCESSFUL_OK_CONFLICTING_ATTRIBUTES: 0x0002,
35
+
36
+ // Client errors
37
+ CLIENT_ERROR_BAD_REQUEST: 0x0400,
38
+ CLIENT_ERROR_FORBIDDEN: 0x0401,
39
+ CLIENT_ERROR_NOT_AUTHENTICATED: 0x0402,
40
+ CLIENT_ERROR_NOT_AUTHORIZED: 0x0403,
41
+ CLIENT_ERROR_NOT_POSSIBLE: 0x0404,
42
+ CLIENT_ERROR_TIMEOUT: 0x0405,
43
+ CLIENT_ERROR_NOT_FOUND: 0x0406,
44
+ CLIENT_ERROR_DOCUMENT_FORMAT_NOT_SUPPORTED: 0x040a,
45
+
46
+ // Server errors
47
+ SERVER_ERROR_INTERNAL: 0x0500,
48
+ SERVER_ERROR_OPERATION_NOT_SUPPORTED: 0x0501,
49
+ SERVER_ERROR_SERVICE_UNAVAILABLE: 0x0502,
50
+ SERVER_ERROR_DEVICE_ERROR: 0x0504,
51
+ SERVER_ERROR_NOT_ACCEPTING_JOBS: 0x0506,
52
+ SERVER_ERROR_BUSY: 0x0507,
53
+ } as const;
54
+
55
+ /** Delimiter Tags */
56
+ const TAG_DELIMITER = {
57
+ OPERATION_ATTRIBUTES: 0x01,
58
+ JOB_ATTRIBUTES: 0x02,
59
+ END_OF_ATTRIBUTES: 0x03,
60
+ PRINTER_ATTRIBUTES: 0x04,
61
+ UNSUPPORTED_ATTRIBUTES: 0x05,
62
+ } as const;
63
+
64
+ /** Value Tags */
65
+ const TAG_VALUE = {
66
+ // Out-of-band
67
+ UNSUPPORTED: 0x10,
68
+ UNKNOWN: 0x12,
69
+ NO_VALUE: 0x13,
70
+
71
+ // Integer
72
+ INTEGER: 0x21,
73
+ BOOLEAN: 0x22,
74
+ ENUM: 0x23,
75
+
76
+ // Octet string
77
+ OCTET_STRING: 0x30,
78
+ DATE_TIME: 0x31,
79
+ RESOLUTION: 0x32,
80
+ RANGE_OF_INTEGER: 0x33,
81
+ BEG_COLLECTION: 0x34,
82
+ TEXT_WITH_LANGUAGE: 0x35,
83
+ NAME_WITH_LANGUAGE: 0x36,
84
+ END_COLLECTION: 0x37,
85
+
86
+ // Character string
87
+ TEXT_WITHOUT_LANGUAGE: 0x41,
88
+ NAME_WITHOUT_LANGUAGE: 0x42,
89
+ KEYWORD: 0x44,
90
+ URI: 0x45,
91
+ URI_SCHEME: 0x46,
92
+ CHARSET: 0x47,
93
+ NATURAL_LANGUAGE: 0x48,
94
+ MIME_MEDIA_TYPE: 0x49,
95
+ MEMBER_ATTR_NAME: 0x4a,
96
+ } as const;
97
+
98
+ /** Job States */
99
+ const JOB_STATE = {
100
+ PENDING: 3,
101
+ PENDING_HELD: 4,
102
+ PROCESSING: 5,
103
+ PROCESSING_STOPPED: 6,
104
+ CANCELED: 7,
105
+ ABORTED: 8,
106
+ COMPLETED: 9,
107
+ } as const;
108
+
109
+ /** Printer States */
110
+ const PRINTER_STATE = {
111
+ IDLE: 3,
112
+ PROCESSING: 4,
113
+ STOPPED: 5,
114
+ } as const;
115
+
116
+ // ============================================================================
117
+ // Types
118
+ // ============================================================================
119
+
120
+ type TIppValue = string | number | boolean | Date | TIppResolution | TIppRange | TIppValue[];
121
+
122
+ interface TIppResolution {
123
+ crossFeed: number;
124
+ feed: number;
125
+ units: 'dpi' | 'dpcm';
126
+ }
127
+
128
+ interface TIppRange {
129
+ lower: number;
130
+ upper: number;
131
+ }
132
+
133
+ interface IIppAttribute {
134
+ tag: number;
135
+ name: string;
136
+ value: TIppValue;
137
+ }
138
+
139
+ interface IIppMessage {
140
+ version: number;
141
+ operationIdOrStatusCode: number;
142
+ requestId: number;
143
+ operationAttributes: Record<string, TIppValue>;
144
+ jobAttributes?: Record<string, TIppValue>;
145
+ printerAttributes?: Record<string, TIppValue>;
146
+ unsupportedAttributes?: Record<string, TIppValue>;
147
+ data?: Buffer;
148
+ }
149
+
150
+ export interface IIppPrinterCapabilities {
151
+ // Basic info
152
+ printerName: string;
153
+ printerInfo?: string;
154
+ printerMakeAndModel?: string;
155
+ printerLocation?: string;
156
+ printerUri: string;
157
+
158
+ // State
159
+ printerState: 'idle' | 'processing' | 'stopped';
160
+ printerStateReasons: string[];
161
+ printerIsAcceptingJobs: boolean;
162
+ queuedJobCount: number;
163
+
164
+ // Document formats
165
+ documentFormatSupported: string[];
166
+ documentFormatDefault?: string;
167
+
168
+ // Media
169
+ mediaSizeSupported: string[];
170
+ mediaDefault?: string;
171
+ mediaTypeSupported: string[];
172
+
173
+ // Capabilities
174
+ colorSupported: boolean;
175
+ sidesSupported: string[];
176
+ sidesDefault?: string;
177
+ copiesSupported: TIppRange;
178
+ printQualitySupported: number[];
179
+ resolutionsSupported: TIppResolution[];
180
+
181
+ // Additional
182
+ operationsSupported: number[];
183
+ ippVersionsSupported: string[];
184
+ }
185
+
186
+ export interface IIppPrintOptions {
187
+ jobName?: string;
188
+ requestingUserName?: string;
189
+ documentFormat?: string;
190
+ copies?: number;
191
+ sides?: 'one-sided' | 'two-sided-long-edge' | 'two-sided-short-edge';
192
+ media?: string;
193
+ printQuality?: 'draft' | 'normal' | 'high';
194
+ colorMode?: 'monochrome' | 'color' | 'auto';
195
+ orientation?: 'portrait' | 'landscape' | 'reverse-landscape' | 'reverse-portrait';
196
+ }
197
+
198
+ export interface IIppJob {
199
+ id: number;
200
+ uri: string;
201
+ state: 'pending' | 'pending-held' | 'processing' | 'processing-stopped' | 'canceled' | 'aborted' | 'completed';
202
+ stateReasons: string[];
203
+ name: string;
204
+ originatingUserName?: string;
205
+ createdAt?: Date;
206
+ completedAt?: Date;
207
+ processingAt?: Date;
208
+ impressionsCompleted?: number;
209
+ }
210
+
211
+ // ============================================================================
212
+ // IPP Message Encoder
213
+ // ============================================================================
214
+
215
+ class IppEncoder {
216
+ private buffer: number[] = [];
217
+
218
+ public encode(message: IIppMessage): Buffer {
219
+ this.buffer = [];
220
+
221
+ // Version
222
+ this.writeInt16(message.version);
223
+
224
+ // Operation ID or Status Code
225
+ this.writeInt16(message.operationIdOrStatusCode);
226
+
227
+ // Request ID
228
+ this.writeInt32(message.requestId);
229
+
230
+ // Operation attributes (always required)
231
+ this.writeDelimiter(TAG_DELIMITER.OPERATION_ATTRIBUTES);
232
+ this.writeAttributes(message.operationAttributes);
233
+
234
+ // Job attributes (optional)
235
+ if (message.jobAttributes && Object.keys(message.jobAttributes).length > 0) {
236
+ this.writeDelimiter(TAG_DELIMITER.JOB_ATTRIBUTES);
237
+ this.writeAttributes(message.jobAttributes);
238
+ }
239
+
240
+ // End of attributes
241
+ this.writeDelimiter(TAG_DELIMITER.END_OF_ATTRIBUTES);
242
+
243
+ // Data (for Print-Job)
244
+ const headerBuffer = Buffer.from(this.buffer);
245
+
246
+ if (message.data) {
247
+ return Buffer.concat([headerBuffer, message.data]);
248
+ }
249
+
250
+ return headerBuffer;
18
251
  }
19
252
 
20
- /**
21
- * Get printer attributes/capabilities
22
- */
23
- public async getAttributes(): Promise<IPrinterCapabilities> {
24
- return new Promise((resolve, reject) => {
25
- this.printer.execute(
26
- 'Get-Printer-Attributes',
27
- null,
28
- (err: Error | null, res: Record<string, unknown>) => {
29
- if (err) {
30
- reject(err);
31
- return;
32
- }
33
-
34
- try {
35
- const attrs = res['printer-attributes-tag'] as Record<string, unknown> || {};
36
- resolve(this.parseCapabilities(attrs));
37
- } catch (parseErr) {
38
- reject(parseErr);
39
- }
40
- }
41
- );
42
- });
253
+ private writeInt8(value: number): void {
254
+ this.buffer.push(value & 0xff);
43
255
  }
44
256
 
45
- /**
46
- * Print a document
47
- */
48
- public async print(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
49
- const msg = this.buildPrintMessage(options);
50
-
51
- return new Promise((resolve, reject) => {
52
- this.printer.execute(
53
- 'Print-Job',
54
- { ...msg, data },
55
- (err: Error | null, res: Record<string, unknown>) => {
56
- if (err) {
57
- reject(err);
58
- return;
59
- }
60
-
61
- try {
62
- const jobAttrs = res['job-attributes-tag'] as Record<string, unknown> || {};
63
- resolve(this.parseJobInfo(jobAttrs));
64
- } catch (parseErr) {
65
- reject(parseErr);
66
- }
67
- }
68
- );
69
- });
257
+ private writeInt16(value: number): void {
258
+ this.buffer.push((value >> 8) & 0xff);
259
+ this.buffer.push(value & 0xff);
70
260
  }
71
261
 
72
- /**
73
- * Get all jobs
74
- */
75
- public async getJobs(): Promise<IPrintJob[]> {
76
- return new Promise((resolve, reject) => {
77
- this.printer.execute(
78
- 'Get-Jobs',
79
- {
80
- 'operation-attributes-tag': {
81
- 'requesting-user-name': 'devicemanager',
82
- 'which-jobs': 'not-completed',
83
- },
84
- },
85
- (err: Error | null, res: Record<string, unknown>) => {
86
- if (err) {
87
- reject(err);
88
- return;
89
- }
90
-
91
- try {
92
- const jobs: IPrintJob[] = [];
93
- const jobTags = res['job-attributes-tag'];
94
-
95
- if (Array.isArray(jobTags)) {
96
- for (const jobAttrs of jobTags) {
97
- jobs.push(this.parseJobInfo(jobAttrs as Record<string, unknown>));
98
- }
99
- } else if (jobTags && typeof jobTags === 'object') {
100
- jobs.push(this.parseJobInfo(jobTags as Record<string, unknown>));
101
- }
102
-
103
- resolve(jobs);
104
- } catch (parseErr) {
105
- reject(parseErr);
106
- }
107
- }
108
- );
109
- });
262
+ private writeInt32(value: number): void {
263
+ this.buffer.push((value >> 24) & 0xff);
264
+ this.buffer.push((value >> 16) & 0xff);
265
+ this.buffer.push((value >> 8) & 0xff);
266
+ this.buffer.push(value & 0xff);
110
267
  }
111
268
 
112
- /**
113
- * Get specific job info
114
- */
115
- public async getJobInfo(jobId: number): Promise<IPrintJob> {
116
- return new Promise((resolve, reject) => {
117
- this.printer.execute(
118
- 'Get-Job-Attributes',
119
- {
120
- 'operation-attributes-tag': {
121
- 'job-id': jobId,
122
- },
123
- },
124
- (err: Error | null, res: Record<string, unknown>) => {
125
- if (err) {
126
- reject(err);
127
- return;
128
- }
129
-
130
- try {
131
- const jobAttrs = res['job-attributes-tag'] as Record<string, unknown> || {};
132
- resolve(this.parseJobInfo(jobAttrs));
133
- } catch (parseErr) {
134
- reject(parseErr);
135
- }
136
- }
137
- );
138
- });
269
+ private writeString(value: string): void {
270
+ const bytes = Buffer.from(value, 'utf-8');
271
+ this.writeInt16(bytes.length);
272
+ for (const byte of bytes) {
273
+ this.buffer.push(byte);
274
+ }
139
275
  }
140
276
 
141
- /**
142
- * Cancel a job
143
- */
144
- public async cancelJob(jobId: number): Promise<void> {
145
- return new Promise((resolve, reject) => {
146
- this.printer.execute(
147
- 'Cancel-Job',
148
- {
149
- 'operation-attributes-tag': {
150
- 'job-id': jobId,
151
- },
152
- },
153
- (err: Error | null, _res: Record<string, unknown>) => {
154
- if (err) {
155
- reject(err);
156
- return;
157
- }
158
- resolve();
159
- }
160
- );
161
- });
277
+ private writeDelimiter(tag: number): void {
278
+ this.writeInt8(tag);
162
279
  }
163
280
 
164
- /**
165
- * Check if printer is available
166
- */
167
- public async checkAvailability(): Promise<boolean> {
168
- try {
169
- await this.getAttributes();
170
- return true;
171
- } catch {
172
- return false;
281
+ private writeAttributes(attrs: Record<string, TIppValue>): void {
282
+ for (const [name, value] of Object.entries(attrs)) {
283
+ this.writeAttribute(name, value);
173
284
  }
174
285
  }
175
286
 
176
- /**
177
- * Build IPP print message from options
178
- */
179
- private buildPrintMessage(options?: IPrintOptions): Record<string, unknown> {
180
- const operationAttrs: Record<string, unknown> = {
181
- 'requesting-user-name': 'devicemanager',
182
- 'job-name': options?.jobName ?? 'Print Job',
183
- 'document-format': 'application/octet-stream',
287
+ private writeAttribute(name: string, value: TIppValue): void {
288
+ if (Array.isArray(value)) {
289
+ // Multi-value attribute
290
+ for (let i = 0; i < value.length; i++) {
291
+ this.writeSingleAttribute(i === 0 ? name : '', value[i], name);
292
+ }
293
+ } else {
294
+ this.writeSingleAttribute(name, value, name);
295
+ }
296
+ }
297
+
298
+ private writeSingleAttribute(name: string, value: TIppValue, originalName?: string): void {
299
+ const tag = this.inferTag(originalName || name, value);
300
+ this.writeInt8(tag);
301
+
302
+ // Name
303
+ const nameBytes = Buffer.from(name, 'utf-8');
304
+ this.writeInt16(nameBytes.length);
305
+ for (const byte of nameBytes) {
306
+ this.buffer.push(byte);
307
+ }
308
+
309
+ // Value
310
+ this.writeValue(tag, value);
311
+ }
312
+
313
+ private inferTag(name: string, value: TIppValue): number {
314
+ // IPP requires specific tags for certain attributes
315
+ const knownTags: Record<string, number> = {
316
+ // Operation attributes
317
+ 'attributes-charset': TAG_VALUE.CHARSET,
318
+ 'attributes-natural-language': TAG_VALUE.NATURAL_LANGUAGE,
319
+ 'printer-uri': TAG_VALUE.URI,
320
+ 'job-uri': TAG_VALUE.URI,
321
+ 'job-name': TAG_VALUE.NAME_WITHOUT_LANGUAGE,
322
+ 'requesting-user-name': TAG_VALUE.NAME_WITHOUT_LANGUAGE,
323
+ 'document-name': TAG_VALUE.NAME_WITHOUT_LANGUAGE,
324
+ 'document-format': TAG_VALUE.MIME_MEDIA_TYPE,
325
+ // Job attributes - keywords
326
+ 'media': TAG_VALUE.KEYWORD,
327
+ 'media-type': TAG_VALUE.KEYWORD,
328
+ 'sides': TAG_VALUE.KEYWORD,
329
+ 'print-color-mode': TAG_VALUE.KEYWORD,
330
+ 'output-bin': TAG_VALUE.KEYWORD,
331
+ 'which-jobs': TAG_VALUE.KEYWORD,
332
+ // Job attributes - enums (not integers!)
333
+ 'print-quality': TAG_VALUE.ENUM,
334
+ 'orientation-requested': TAG_VALUE.ENUM,
335
+ 'finishings': TAG_VALUE.ENUM,
336
+ // Job attributes - integers
337
+ 'copies': TAG_VALUE.INTEGER,
338
+ 'job-id': TAG_VALUE.INTEGER,
339
+ 'job-priority': TAG_VALUE.INTEGER,
184
340
  };
185
341
 
186
- const jobAttrs: Record<string, unknown> = {};
342
+ if (knownTags[name]) {
343
+ return knownTags[name];
344
+ }
187
345
 
188
- if (options?.copies && options.copies > 1) {
189
- jobAttrs['copies'] = options.copies;
346
+ // Infer from value type
347
+ if (typeof value === 'boolean') return TAG_VALUE.BOOLEAN;
348
+ if (typeof value === 'number') return TAG_VALUE.INTEGER;
349
+ if (value instanceof Date) return TAG_VALUE.DATE_TIME;
350
+ if (typeof value === 'object' && 'crossFeed' in value) return TAG_VALUE.RESOLUTION;
351
+ if (typeof value === 'object' && 'lower' in value) return TAG_VALUE.RANGE_OF_INTEGER;
352
+
353
+ // String - try to infer type
354
+ const str = String(value);
355
+ if (str.startsWith('ipp://') || str.startsWith('ipps://') || str.startsWith('http://')) {
356
+ return TAG_VALUE.URI;
190
357
  }
358
+ if (str.includes('/')) return TAG_VALUE.MIME_MEDIA_TYPE; // e.g., application/pdf
359
+ if (str.match(/^[a-z][a-z0-9-]*$/)) return TAG_VALUE.KEYWORD;
360
+
361
+ return TAG_VALUE.NAME_WITHOUT_LANGUAGE;
362
+ }
363
+
364
+ private writeValue(tag: number, value: TIppValue): void {
365
+ switch (tag) {
366
+ case TAG_VALUE.INTEGER:
367
+ case TAG_VALUE.ENUM:
368
+ this.writeInt16(4);
369
+ this.writeInt32(value as number);
370
+ break;
191
371
 
192
- if (options?.mediaSize) {
193
- jobAttrs['media'] = options.mediaSize;
372
+ case TAG_VALUE.BOOLEAN:
373
+ this.writeInt16(1);
374
+ this.writeInt8(value ? 1 : 0);
375
+ break;
376
+
377
+ case TAG_VALUE.RESOLUTION: {
378
+ const res = value as TIppResolution;
379
+ this.writeInt16(9);
380
+ this.writeInt32(res.crossFeed);
381
+ this.writeInt32(res.feed);
382
+ this.writeInt8(res.units === 'dpi' ? 3 : 4);
383
+ break;
384
+ }
385
+
386
+ case TAG_VALUE.RANGE_OF_INTEGER: {
387
+ const range = value as TIppRange;
388
+ this.writeInt16(8);
389
+ this.writeInt32(range.lower);
390
+ this.writeInt32(range.upper);
391
+ break;
392
+ }
393
+
394
+ case TAG_VALUE.DATE_TIME: {
395
+ const date = value as Date;
396
+ this.writeInt16(11);
397
+ this.writeInt16(date.getUTCFullYear());
398
+ this.writeInt8(date.getUTCMonth() + 1);
399
+ this.writeInt8(date.getUTCDate());
400
+ this.writeInt8(date.getUTCHours());
401
+ this.writeInt8(date.getUTCMinutes());
402
+ this.writeInt8(date.getUTCSeconds());
403
+ this.writeInt8(0); // deciseconds
404
+ this.writeInt8('+'.charCodeAt(0));
405
+ this.writeInt8(0); // UTC offset hours
406
+ this.writeInt8(0); // UTC offset minutes
407
+ break;
408
+ }
409
+
410
+ default:
411
+ // String types
412
+ this.writeString(String(value));
413
+ break;
194
414
  }
415
+ }
416
+ }
417
+
418
+ // ============================================================================
419
+ // IPP Message Decoder
420
+ // ============================================================================
195
421
 
196
- if (options?.mediaType) {
197
- jobAttrs['media-type'] = options.mediaType;
422
+ class IppDecoder {
423
+ private buffer: Buffer = Buffer.alloc(0);
424
+ private offset: number = 0;
425
+
426
+ public decode(data: Buffer): IIppMessage {
427
+ this.buffer = data;
428
+ this.offset = 0;
429
+
430
+ const version = this.readInt16();
431
+ const operationIdOrStatusCode = this.readInt16();
432
+ const requestId = this.readInt32();
433
+
434
+ const message: IIppMessage = {
435
+ version,
436
+ operationIdOrStatusCode,
437
+ requestId,
438
+ operationAttributes: {},
439
+ };
440
+
441
+ // Read attribute groups
442
+ while (this.offset < this.buffer.length) {
443
+ const tag = this.readInt8();
444
+
445
+ if (tag === TAG_DELIMITER.END_OF_ATTRIBUTES) {
446
+ // Remaining data is document data
447
+ if (this.offset < this.buffer.length) {
448
+ message.data = this.buffer.subarray(this.offset);
449
+ }
450
+ break;
451
+ }
452
+
453
+ if (tag === TAG_DELIMITER.OPERATION_ATTRIBUTES) {
454
+ message.operationAttributes = this.readAttributes();
455
+ } else if (tag === TAG_DELIMITER.JOB_ATTRIBUTES) {
456
+ message.jobAttributes = this.readAttributes();
457
+ } else if (tag === TAG_DELIMITER.PRINTER_ATTRIBUTES) {
458
+ message.printerAttributes = this.readAttributes();
459
+ } else if (tag === TAG_DELIMITER.UNSUPPORTED_ATTRIBUTES) {
460
+ message.unsupportedAttributes = this.readAttributes();
461
+ }
198
462
  }
199
463
 
200
- if (options?.sides) {
201
- jobAttrs['sides'] = options.sides;
464
+ return message;
465
+ }
466
+
467
+ private readInt8(): number {
468
+ return this.buffer[this.offset++];
469
+ }
470
+
471
+ private readInt16(): number {
472
+ const value = this.buffer.readUInt16BE(this.offset);
473
+ this.offset += 2;
474
+ return value;
475
+ }
476
+
477
+ private readInt32(): number {
478
+ const value = this.buffer.readInt32BE(this.offset);
479
+ this.offset += 4;
480
+ return value;
481
+ }
482
+
483
+ private readUInt32(): number {
484
+ const value = this.buffer.readUInt32BE(this.offset);
485
+ this.offset += 4;
486
+ return value;
487
+ }
488
+
489
+ private readString(length: number): string {
490
+ const str = this.buffer.toString('utf-8', this.offset, this.offset + length);
491
+ this.offset += length;
492
+ return str;
493
+ }
494
+
495
+ private readAttributes(): Record<string, TIppValue> {
496
+ const attrs: Record<string, TIppValue> = {};
497
+ let currentName = '';
498
+
499
+ while (this.offset < this.buffer.length) {
500
+ const tag = this.buffer[this.offset];
501
+
502
+ // Check for delimiter tag (start of new group or end)
503
+ if (tag <= 0x0f) {
504
+ break;
505
+ }
506
+
507
+ this.offset++; // Consume the tag
508
+
509
+ const nameLength = this.readInt16();
510
+ const name = nameLength > 0 ? this.readString(nameLength) : currentName;
511
+ const valueLength = this.readInt16();
512
+
513
+ const value = this.readValue(tag, valueLength);
514
+
515
+ if (nameLength > 0) {
516
+ currentName = name;
517
+ attrs[name] = value;
518
+ } else {
519
+ // Additional value for same attribute - make it an array
520
+ const existing = attrs[currentName];
521
+ if (Array.isArray(existing)) {
522
+ existing.push(value);
523
+ } else {
524
+ attrs[currentName] = [existing, value];
525
+ }
526
+ }
202
527
  }
203
528
 
204
- if (options?.quality) {
205
- const qualityMap: Record<string, number> = {
206
- draft: 3,
207
- normal: 4,
208
- high: 5,
209
- };
210
- jobAttrs['print-quality'] = qualityMap[options.quality] ?? 4;
529
+ return attrs;
530
+ }
531
+
532
+ private readValue(tag: number, length: number): TIppValue {
533
+ switch (tag) {
534
+ case TAG_VALUE.INTEGER:
535
+ case TAG_VALUE.ENUM:
536
+ return this.readInt32();
537
+
538
+ case TAG_VALUE.BOOLEAN:
539
+ return this.readInt8() !== 0;
540
+
541
+ case TAG_VALUE.RESOLUTION: {
542
+ const crossFeed = this.readInt32();
543
+ const feed = this.readInt32();
544
+ const units = this.readInt8() === 3 ? 'dpi' : 'dpcm';
545
+ return { crossFeed, feed, units } as TIppResolution;
546
+ }
547
+
548
+ case TAG_VALUE.RANGE_OF_INTEGER: {
549
+ const lower = this.readInt32();
550
+ const upper = this.readInt32();
551
+ return { lower, upper } as TIppRange;
552
+ }
553
+
554
+ case TAG_VALUE.DATE_TIME: {
555
+ const year = this.readInt16();
556
+ const month = this.readInt8();
557
+ const day = this.readInt8();
558
+ const hour = this.readInt8();
559
+ const minute = this.readInt8();
560
+ const second = this.readInt8();
561
+ this.readInt8(); // deciseconds
562
+ const direction = String.fromCharCode(this.readInt8());
563
+ const offsetHours = this.readInt8();
564
+ const offsetMinutes = this.readInt8();
565
+
566
+ const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
567
+ const offsetMs = (offsetHours * 60 + offsetMinutes) * 60 * 1000;
568
+ if (direction === '-') {
569
+ date.setTime(date.getTime() + offsetMs);
570
+ } else {
571
+ date.setTime(date.getTime() - offsetMs);
572
+ }
573
+ return date;
574
+ }
575
+
576
+ case TAG_VALUE.NO_VALUE:
577
+ case TAG_VALUE.UNKNOWN:
578
+ case TAG_VALUE.UNSUPPORTED:
579
+ this.offset += length;
580
+ return '';
581
+
582
+ default:
583
+ // String types
584
+ return this.readString(length);
211
585
  }
586
+ }
587
+ }
212
588
 
213
- if (options?.colorMode) {
214
- jobAttrs['print-color-mode'] = options.colorMode;
589
+ // ============================================================================
590
+ // Status Code Helpers
591
+ // ============================================================================
592
+
593
+ function isSuccessful(statusCode: number): boolean {
594
+ return statusCode >= 0x0000 && statusCode <= 0x00ff;
595
+ }
596
+
597
+ function statusCodeToString(code: number): string {
598
+ const statusMap: Record<number, string> = {
599
+ 0x0000: 'successful-ok',
600
+ 0x0001: 'successful-ok-ignored-or-substituted-attributes',
601
+ 0x0002: 'successful-ok-conflicting-attributes',
602
+ 0x0400: 'client-error-bad-request',
603
+ 0x0401: 'client-error-forbidden',
604
+ 0x0402: 'client-error-not-authenticated',
605
+ 0x0403: 'client-error-not-authorized',
606
+ 0x0404: 'client-error-not-possible',
607
+ 0x0405: 'client-error-timeout',
608
+ 0x0406: 'client-error-not-found',
609
+ 0x040a: 'client-error-document-format-not-supported',
610
+ 0x040b: 'client-error-attributes-or-values-not-supported',
611
+ 0x0500: 'server-error-internal-error',
612
+ 0x0501: 'server-error-operation-not-supported',
613
+ 0x0502: 'server-error-service-unavailable',
614
+ 0x0504: 'server-error-device-error',
615
+ 0x0506: 'server-error-not-accepting-jobs',
616
+ 0x0507: 'server-error-busy',
617
+ };
618
+ return statusMap[code] || `unknown-status-${code.toString(16)}`;
619
+ }
620
+
621
+ // ============================================================================
622
+ // IPP Client
623
+ // ============================================================================
624
+
625
+ export class IppClient {
626
+ private readonly printerUri: string;
627
+ private readonly httpUri: string;
628
+ private requestId: number = 1;
629
+ private readonly encoder = new IppEncoder();
630
+ private readonly decoder = new IppDecoder();
631
+
632
+ constructor(
633
+ address: string,
634
+ port: number = 631,
635
+ path: string = '/ipp/print'
636
+ ) {
637
+ this.printerUri = `ipp://${address}:${port}${path}`;
638
+ this.httpUri = `http://${address}:${port}${path}`;
639
+ }
640
+
641
+ // ==========================================================================
642
+ // Core Request Method
643
+ // ==========================================================================
644
+
645
+ private async request(message: IIppMessage): Promise<IIppMessage> {
646
+ const requestData = this.encoder.encode(message);
647
+
648
+ const response = await fetch(this.httpUri, {
649
+ method: 'POST',
650
+ headers: {
651
+ 'Content-Type': 'application/ipp',
652
+ 'Accept': 'application/ipp',
653
+ },
654
+ body: new Uint8Array(requestData),
655
+ });
656
+
657
+ if (!response.ok) {
658
+ throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
215
659
  }
216
660
 
217
- const msg: Record<string, unknown> = {
218
- 'operation-attributes-tag': operationAttrs,
661
+ const responseData = Buffer.from(await response.arrayBuffer());
662
+ return this.decoder.decode(responseData);
663
+ }
664
+
665
+ // ==========================================================================
666
+ // Get Printer Attributes
667
+ // ==========================================================================
668
+
669
+ public async getPrinterAttributes(): Promise<IIppPrinterCapabilities> {
670
+ const message: IIppMessage = {
671
+ version: IPP_VERSION.V2_0,
672
+ operationIdOrStatusCode: IPP_OPERATION.GET_PRINTER_ATTRIBUTES,
673
+ requestId: this.requestId++,
674
+ operationAttributes: {
675
+ 'attributes-charset': 'utf-8',
676
+ 'attributes-natural-language': 'en-us',
677
+ 'printer-uri': this.printerUri,
678
+ },
219
679
  };
220
680
 
221
- if (Object.keys(jobAttrs).length > 0) {
222
- msg['job-attributes-tag'] = jobAttrs;
681
+ const response = await this.request(message);
682
+
683
+ if (!isSuccessful(response.operationIdOrStatusCode)) {
684
+ throw new Error(`IPP error: ${statusCodeToString(response.operationIdOrStatusCode)}`);
223
685
  }
224
686
 
225
- return msg;
687
+ return this.parseCapabilities(response.printerAttributes || {});
226
688
  }
227
689
 
228
- /**
229
- * Parse printer capabilities from attributes
230
- */
231
- private parseCapabilities(attrs: Record<string, unknown>): IPrinterCapabilities {
232
- const getArray = (key: string): string[] => {
233
- const value = attrs[key];
234
- if (Array.isArray(value)) return value.map(String);
235
- if (value !== undefined) return [String(value)];
690
+ private parseCapabilities(attrs: Record<string, TIppValue>): IIppPrinterCapabilities {
691
+ const getString = (key: string, defaultVal: string = ''): string => {
692
+ const val = attrs[key];
693
+ if (typeof val === 'string') return val;
694
+ if (Array.isArray(val) && val.length > 0) return String(val[0]);
695
+ return defaultVal;
696
+ };
697
+
698
+ const getStringArray = (key: string): string[] => {
699
+ const val = attrs[key];
700
+ if (Array.isArray(val)) return val.map(String);
701
+ if (val !== undefined) return [String(val)];
236
702
  return [];
237
703
  };
238
704
 
239
- const getNumber = (key: string, defaultVal: number): number => {
240
- const value = attrs[key];
241
- if (typeof value === 'number') return value;
242
- if (typeof value === 'string') return parseInt(value) || defaultVal;
705
+ const getNumber = (key: string, defaultVal: number = 0): number => {
706
+ const val = attrs[key];
707
+ if (typeof val === 'number') return val;
243
708
  return defaultVal;
244
709
  };
245
710
 
246
- const getBool = (key: string, defaultVal: boolean): boolean => {
247
- const value = attrs[key];
248
- if (typeof value === 'boolean') return value;
249
- if (value === 'true' || value === 1) return true;
250
- if (value === 'false' || value === 0) return false;
711
+ const getNumberArray = (key: string): number[] => {
712
+ const val = attrs[key];
713
+ if (Array.isArray(val)) return val.filter((v): v is number => typeof v === 'number');
714
+ if (typeof val === 'number') return [val];
715
+ return [];
716
+ };
717
+
718
+ const getBool = (key: string, defaultVal: boolean = false): boolean => {
719
+ const val = attrs[key];
720
+ if (typeof val === 'boolean') return val;
251
721
  return defaultVal;
252
722
  };
253
723
 
254
- // Parse resolutions
255
- const resolutions: number[] = [];
256
- const resSupported = attrs['printer-resolution-supported'];
257
- if (Array.isArray(resSupported)) {
258
- for (const res of resSupported) {
259
- if (typeof res === 'object' && res !== null && 'x' in res) {
260
- resolutions.push((res as { x: number }).x);
261
- } else if (typeof res === 'number') {
262
- resolutions.push(res);
263
- }
724
+ const getRange = (key: string): TIppRange => {
725
+ const val = attrs[key];
726
+ if (val && typeof val === 'object' && 'lower' in val) {
727
+ return val as TIppRange;
264
728
  }
265
- }
266
- if (resolutions.length === 0) {
267
- resolutions.push(300, 600);
268
- }
729
+ return { lower: 1, upper: 1 };
730
+ };
731
+
732
+ const getResolutions = (): TIppResolution[] => {
733
+ const val = attrs['printer-resolution-supported'];
734
+ if (!val) return [];
735
+
736
+ const values = Array.isArray(val) ? val : [val];
737
+ return values
738
+ .filter((v): v is TIppResolution =>
739
+ typeof v === 'object' && v !== null && 'crossFeed' in v
740
+ );
741
+ };
742
+
743
+ // Map printer state number to string
744
+ const printerState = getNumber('printer-state', PRINTER_STATE.IDLE);
745
+ const printerStateString = printerState === PRINTER_STATE.IDLE ? 'idle' :
746
+ printerState === PRINTER_STATE.PROCESSING ? 'processing' : 'stopped';
269
747
 
270
748
  return {
749
+ printerName: getString('printer-name', 'Unknown Printer'),
750
+ printerInfo: getString('printer-info') || undefined,
751
+ printerMakeAndModel: getString('printer-make-and-model') || undefined,
752
+ printerLocation: getString('printer-location') || undefined,
753
+ printerUri: this.printerUri,
754
+
755
+ printerState: printerStateString,
756
+ printerStateReasons: getStringArray('printer-state-reasons'),
757
+ printerIsAcceptingJobs: getBool('printer-is-accepting-jobs', true),
758
+ queuedJobCount: getNumber('queued-job-count', 0),
759
+
760
+ documentFormatSupported: getStringArray('document-format-supported'),
761
+ documentFormatDefault: getString('document-format-default') || undefined,
762
+
763
+ mediaSizeSupported: getStringArray('media-supported'),
764
+ mediaDefault: getString('media-default') || undefined,
765
+ mediaTypeSupported: getStringArray('media-type-supported'),
766
+
271
767
  colorSupported: getBool('color-supported', false),
272
- duplexSupported:
273
- getArray('sides-supported').some((s) =>
274
- s.includes('two-sided')
275
- ),
276
- mediaSizes: getArray('media-supported'),
277
- mediaTypes: getArray('media-type-supported'),
278
- resolutions: [...new Set(resolutions)],
279
- maxCopies: getNumber('copies-supported', 99),
280
- sidesSupported: getArray('sides-supported'),
281
- qualitySupported: getArray('print-quality-supported').map(String),
768
+ sidesSupported: getStringArray('sides-supported'),
769
+ sidesDefault: getString('sides-default') || undefined,
770
+ copiesSupported: getRange('copies-supported'),
771
+ printQualitySupported: getNumberArray('print-quality-supported'),
772
+ resolutionsSupported: getResolutions(),
773
+
774
+ operationsSupported: getNumberArray('operations-supported'),
775
+ ippVersionsSupported: getStringArray('ipp-versions-supported'),
282
776
  };
283
777
  }
284
778
 
285
- /**
286
- * Parse job info from attributes
287
- */
288
- private parseJobInfo(attrs: Record<string, unknown>): IPrintJob {
289
- const getString = (key: string, defaultVal: string): string => {
290
- const value = attrs[key];
291
- if (typeof value === 'string') return value;
292
- if (value !== undefined) return String(value);
779
+ // ==========================================================================
780
+ // Print Job
781
+ // ==========================================================================
782
+
783
+ public async printJob(
784
+ data: Buffer,
785
+ options: IIppPrintOptions = {}
786
+ ): Promise<IIppJob> {
787
+ // Build operation attributes
788
+ const operationAttrs: Record<string, TIppValue> = {
789
+ 'attributes-charset': 'utf-8',
790
+ 'attributes-natural-language': 'en-us',
791
+ 'printer-uri': this.printerUri,
792
+ 'requesting-user-name': options.requestingUserName || 'devicemanager',
793
+ 'job-name': options.jobName || 'Print Job',
794
+ 'document-format': options.documentFormat || 'application/octet-stream',
795
+ };
796
+
797
+ // Build job attributes
798
+ const jobAttrs: Record<string, TIppValue> = {};
799
+
800
+ if (options.copies && options.copies > 1) {
801
+ jobAttrs['copies'] = options.copies;
802
+ }
803
+
804
+ if (options.sides) {
805
+ jobAttrs['sides'] = options.sides;
806
+ }
807
+
808
+ if (options.media) {
809
+ jobAttrs['media'] = options.media;
810
+ }
811
+
812
+ if (options.printQuality) {
813
+ const qualityMap = { draft: 3, normal: 4, high: 5 };
814
+ jobAttrs['print-quality'] = qualityMap[options.printQuality];
815
+ }
816
+
817
+ if (options.colorMode) {
818
+ jobAttrs['print-color-mode'] = options.colorMode;
819
+ }
820
+
821
+ if (options.orientation) {
822
+ const orientationMap = {
823
+ portrait: 3,
824
+ landscape: 4,
825
+ 'reverse-landscape': 5,
826
+ 'reverse-portrait': 6,
827
+ };
828
+ jobAttrs['orientation-requested'] = orientationMap[options.orientation];
829
+ }
830
+
831
+ const message: IIppMessage = {
832
+ version: IPP_VERSION.V2_0,
833
+ operationIdOrStatusCode: IPP_OPERATION.PRINT_JOB,
834
+ requestId: this.requestId++,
835
+ operationAttributes: operationAttrs,
836
+ jobAttributes: Object.keys(jobAttrs).length > 0 ? jobAttrs : undefined,
837
+ data,
838
+ };
839
+
840
+ const response = await this.request(message);
841
+
842
+ if (!isSuccessful(response.operationIdOrStatusCode)) {
843
+ throw new Error(`Print failed: ${statusCodeToString(response.operationIdOrStatusCode)}`);
844
+ }
845
+
846
+ return this.parseJob(response.jobAttributes || {});
847
+ }
848
+
849
+ // ==========================================================================
850
+ // Get Jobs
851
+ // ==========================================================================
852
+
853
+ public async getJobs(whichJobs: 'completed' | 'not-completed' | 'all' = 'not-completed'): Promise<IIppJob[]> {
854
+ const message: IIppMessage = {
855
+ version: IPP_VERSION.V2_0,
856
+ operationIdOrStatusCode: IPP_OPERATION.GET_JOBS,
857
+ requestId: this.requestId++,
858
+ operationAttributes: {
859
+ 'attributes-charset': 'utf-8',
860
+ 'attributes-natural-language': 'en-us',
861
+ 'printer-uri': this.printerUri,
862
+ 'requesting-user-name': 'devicemanager',
863
+ 'which-jobs': whichJobs,
864
+ },
865
+ };
866
+
867
+ const response = await this.request(message);
868
+
869
+ if (!isSuccessful(response.operationIdOrStatusCode)) {
870
+ throw new Error(`Get jobs failed: ${statusCodeToString(response.operationIdOrStatusCode)}`);
871
+ }
872
+
873
+ // Jobs can come back as a single job-attributes-tag or multiple
874
+ // The decoder merges additional values into arrays
875
+ if (!response.jobAttributes) {
876
+ return [];
877
+ }
878
+
879
+ // If job-id is an array, we have multiple jobs encoded together
880
+ // This is a limitation of the simple decoder - in reality each job is a separate group
881
+ // For now, return single job if present
882
+ return [this.parseJob(response.jobAttributes)];
883
+ }
884
+
885
+ // ==========================================================================
886
+ // Get Job Attributes
887
+ // ==========================================================================
888
+
889
+ public async getJobAttributes(jobId: number): Promise<IIppJob> {
890
+ const message: IIppMessage = {
891
+ version: IPP_VERSION.V2_0,
892
+ operationIdOrStatusCode: IPP_OPERATION.GET_JOB_ATTRIBUTES,
893
+ requestId: this.requestId++,
894
+ operationAttributes: {
895
+ 'attributes-charset': 'utf-8',
896
+ 'attributes-natural-language': 'en-us',
897
+ 'printer-uri': this.printerUri,
898
+ 'job-id': jobId,
899
+ },
900
+ };
901
+
902
+ const response = await this.request(message);
903
+
904
+ if (!isSuccessful(response.operationIdOrStatusCode)) {
905
+ throw new Error(`Get job failed: ${statusCodeToString(response.operationIdOrStatusCode)}`);
906
+ }
907
+
908
+ return this.parseJob(response.jobAttributes || {});
909
+ }
910
+
911
+ // ==========================================================================
912
+ // Cancel Job
913
+ // ==========================================================================
914
+
915
+ public async cancelJob(jobId: number): Promise<void> {
916
+ const message: IIppMessage = {
917
+ version: IPP_VERSION.V2_0,
918
+ operationIdOrStatusCode: IPP_OPERATION.CANCEL_JOB,
919
+ requestId: this.requestId++,
920
+ operationAttributes: {
921
+ 'attributes-charset': 'utf-8',
922
+ 'attributes-natural-language': 'en-us',
923
+ 'printer-uri': this.printerUri,
924
+ 'job-id': jobId,
925
+ },
926
+ };
927
+
928
+ const response = await this.request(message);
929
+
930
+ if (!isSuccessful(response.operationIdOrStatusCode)) {
931
+ throw new Error(`Cancel job failed: ${statusCodeToString(response.operationIdOrStatusCode)}`);
932
+ }
933
+ }
934
+
935
+ // ==========================================================================
936
+ // Validate Job (check if job would succeed without actually printing)
937
+ // ==========================================================================
938
+
939
+ public async validateJob(options: IIppPrintOptions = {}): Promise<boolean> {
940
+ const operationAttrs: Record<string, TIppValue> = {
941
+ 'attributes-charset': 'utf-8',
942
+ 'attributes-natural-language': 'en-us',
943
+ 'printer-uri': this.printerUri,
944
+ 'document-format': options.documentFormat || 'application/octet-stream',
945
+ };
946
+
947
+ const message: IIppMessage = {
948
+ version: IPP_VERSION.V2_0,
949
+ operationIdOrStatusCode: IPP_OPERATION.VALIDATE_JOB,
950
+ requestId: this.requestId++,
951
+ operationAttributes: operationAttrs,
952
+ };
953
+
954
+ const response = await this.request(message);
955
+ return isSuccessful(response.operationIdOrStatusCode);
956
+ }
957
+
958
+ // ==========================================================================
959
+ // Helper Methods
960
+ // ==========================================================================
961
+
962
+ private parseJob(attrs: Record<string, TIppValue>): IIppJob {
963
+ const getNumber = (key: string, defaultVal: number = 0): number => {
964
+ const val = attrs[key];
965
+ if (typeof val === 'number') return val;
966
+ if (Array.isArray(val) && typeof val[0] === 'number') return val[0];
293
967
  return defaultVal;
294
968
  };
295
969
 
296
- const getNumber = (key: string, defaultVal: number): number => {
297
- const value = attrs[key];
298
- if (typeof value === 'number') return value;
299
- if (typeof value === 'string') return parseInt(value) || defaultVal;
970
+ const getString = (key: string, defaultVal: string = ''): string => {
971
+ const val = attrs[key];
972
+ if (typeof val === 'string') return val;
973
+ if (Array.isArray(val) && val.length > 0) return String(val[0]);
300
974
  return defaultVal;
301
975
  };
302
976
 
303
- // Map IPP job state to our state
304
- const ippState = getNumber('job-state', 3);
305
- const stateMap: Record<number, IPrintJob['state']> = {
306
- 3: 'pending', // pending
307
- 4: 'pending', // pending-held
308
- 5: 'processing', // processing
309
- 6: 'processing', // processing-stopped
310
- 7: 'canceled', // canceled
311
- 8: 'aborted', // aborted
312
- 9: 'completed', // completed
977
+ const getStringArray = (key: string): string[] => {
978
+ const val = attrs[key];
979
+ if (Array.isArray(val)) return val.map(String);
980
+ if (val !== undefined) return [String(val)];
981
+ return [];
313
982
  };
314
983
 
315
- const createdTime = attrs['time-at-creation'];
316
- const completedTime = attrs['time-at-completed'];
984
+ const getDate = (key: string): Date | undefined => {
985
+ const val = attrs[key];
986
+ if (val instanceof Date) return val;
987
+ return undefined;
988
+ };
989
+
990
+ // Map job state number to string
991
+ const jobState = getNumber('job-state', JOB_STATE.PENDING);
992
+ const jobStateMap: Record<number, IIppJob['state']> = {
993
+ [JOB_STATE.PENDING]: 'pending',
994
+ [JOB_STATE.PENDING_HELD]: 'pending-held',
995
+ [JOB_STATE.PROCESSING]: 'processing',
996
+ [JOB_STATE.PROCESSING_STOPPED]: 'processing-stopped',
997
+ [JOB_STATE.CANCELED]: 'canceled',
998
+ [JOB_STATE.ABORTED]: 'aborted',
999
+ [JOB_STATE.COMPLETED]: 'completed',
1000
+ };
317
1001
 
318
1002
  return {
319
- id: getNumber('job-id', 0),
1003
+ id: getNumber('job-id'),
1004
+ uri: getString('job-uri'),
1005
+ state: jobStateMap[jobState] || 'pending',
1006
+ stateReasons: getStringArray('job-state-reasons'),
320
1007
  name: getString('job-name', 'Unknown Job'),
321
- state: stateMap[ippState] ?? 'pending',
322
- stateReason: getString('job-state-reasons', undefined),
323
- createdAt: createdTime ? new Date(createdTime as number * 1000) : new Date(),
324
- completedAt: completedTime ? new Date(completedTime as number * 1000) : undefined,
325
- pagesPrinted: getNumber('job-media-sheets-completed', undefined),
326
- pagesTotal: getNumber('job-media-sheets', undefined),
1008
+ originatingUserName: getString('job-originating-user-name') || undefined,
1009
+ createdAt: getDate('time-at-creation') || getDate('date-time-at-creation'),
1010
+ completedAt: getDate('time-at-completed') || getDate('date-time-at-completed'),
1011
+ processingAt: getDate('time-at-processing') || getDate('date-time-at-processing'),
1012
+ impressionsCompleted: getNumber('job-impressions-completed') || undefined,
1013
+ };
1014
+ }
1015
+
1016
+ // ==========================================================================
1017
+ // Convenience: Check if format is supported
1018
+ // ==========================================================================
1019
+
1020
+ public async isFormatSupported(mimeType: string): Promise<boolean> {
1021
+ const caps = await this.getPrinterAttributes();
1022
+ return caps.documentFormatSupported.includes(mimeType);
1023
+ }
1024
+
1025
+ // ==========================================================================
1026
+ // Convenience: Get best format for data
1027
+ // ==========================================================================
1028
+
1029
+ public async getBestFormat(preferredFormats: string[]): Promise<string | null> {
1030
+ const caps = await this.getPrinterAttributes();
1031
+ const supported = caps.documentFormatSupported;
1032
+
1033
+ for (const format of preferredFormats) {
1034
+ if (supported.includes(format)) {
1035
+ return format;
1036
+ }
1037
+ }
1038
+
1039
+ return null;
1040
+ }
1041
+
1042
+ // ==========================================================================
1043
+ // Smart Print: Auto-detect format and convert if needed
1044
+ // ==========================================================================
1045
+
1046
+ /**
1047
+ * Smart print that auto-detects document format and converts if necessary.
1048
+ *
1049
+ * If the document format is not supported by the printer, it will attempt
1050
+ * to convert to a supported format (e.g., PDF → JPEG).
1051
+ */
1052
+ public async smartPrint(
1053
+ data: Buffer,
1054
+ options: IIppPrintOptions = {}
1055
+ ): Promise<IIppJob> {
1056
+ // Detect format from data
1057
+ const detectedFormat = this.detectFormat(data);
1058
+ console.log(`[IPP] Detected format: ${detectedFormat}`);
1059
+
1060
+ // Get printer capabilities
1061
+ const caps = await this.getPrinterAttributes();
1062
+ const supported = caps.documentFormatSupported;
1063
+
1064
+ // Check if detected format is supported
1065
+ if (supported.includes(detectedFormat)) {
1066
+ console.log(`[IPP] Format ${detectedFormat} is supported, printing directly`);
1067
+ return this.printJob(data, {
1068
+ ...options,
1069
+ documentFormat: detectedFormat,
1070
+ });
1071
+ }
1072
+
1073
+ // Format not supported - try to convert
1074
+ console.log(`[IPP] Format ${detectedFormat} not supported, attempting conversion`);
1075
+
1076
+ // Determine best target format
1077
+ const conversionTargets = ['image/jpeg', 'image/png', 'image/pwg-raster'];
1078
+ const targetFormat = conversionTargets.find(f => supported.includes(f));
1079
+
1080
+ if (!targetFormat) {
1081
+ throw new Error(
1082
+ `Document format ${detectedFormat} not supported and no conversion target available. ` +
1083
+ `Printer supports: ${supported.join(', ')}`
1084
+ );
1085
+ }
1086
+
1087
+ // Convert the document
1088
+ const convertedData = await this.convertDocument(data, detectedFormat, targetFormat);
1089
+ console.log(`[IPP] Converted to ${targetFormat} (${convertedData.length} bytes)`);
1090
+
1091
+ return this.printJob(convertedData, {
1092
+ ...options,
1093
+ documentFormat: targetFormat,
1094
+ });
1095
+ }
1096
+
1097
+ /**
1098
+ * Detect document format from magic bytes
1099
+ */
1100
+ private detectFormat(data: Buffer): string {
1101
+ if (data.length < 4) {
1102
+ return 'application/octet-stream';
1103
+ }
1104
+
1105
+ // PDF: starts with %PDF
1106
+ if (data[0] === 0x25 && data[1] === 0x50 && data[2] === 0x44 && data[3] === 0x46) {
1107
+ return 'application/pdf';
1108
+ }
1109
+
1110
+ // JPEG: starts with FFD8FF
1111
+ if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
1112
+ return 'image/jpeg';
1113
+ }
1114
+
1115
+ // PNG: starts with 89504E47
1116
+ if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
1117
+ return 'image/png';
1118
+ }
1119
+
1120
+ // TIFF: starts with 49492A00 (little endian) or 4D4D002A (big endian)
1121
+ if ((data[0] === 0x49 && data[1] === 0x49 && data[2] === 0x2a && data[3] === 0x00) ||
1122
+ (data[0] === 0x4d && data[1] === 0x4d && data[2] === 0x00 && data[3] === 0x2a)) {
1123
+ return 'image/tiff';
1124
+ }
1125
+
1126
+ // PostScript: starts with %!
1127
+ if (data[0] === 0x25 && data[1] === 0x21) {
1128
+ return 'application/postscript';
1129
+ }
1130
+
1131
+ // PWG Raster: starts with "RaS2"
1132
+ if (data[0] === 0x52 && data[1] === 0x61 && data[2] === 0x53 && data[3] === 0x32) {
1133
+ return 'image/pwg-raster';
1134
+ }
1135
+
1136
+ // URF (Apple Raster): starts with "UNIRAST"
1137
+ if (data.length >= 8 &&
1138
+ data[0] === 0x55 && data[1] === 0x4e && data[2] === 0x49 && data[3] === 0x52 &&
1139
+ data[4] === 0x41 && data[5] === 0x53 && data[6] === 0x54) {
1140
+ return 'image/urf';
1141
+ }
1142
+
1143
+ return 'application/octet-stream';
1144
+ }
1145
+
1146
+ /**
1147
+ * Convert document to target format
1148
+ * Uses ImageMagick if available, otherwise throws
1149
+ */
1150
+ private async convertDocument(
1151
+ data: Buffer,
1152
+ sourceFormat: string,
1153
+ targetFormat: string
1154
+ ): Promise<Buffer> {
1155
+ // Try using ImageMagick via child_process
1156
+ const { execSync, spawnSync } = await import('child_process');
1157
+ const { writeFileSync, readFileSync, unlinkSync, mkdtempSync } = await import('fs');
1158
+ const { join } = await import('path');
1159
+ const { tmpdir } = await import('os');
1160
+
1161
+ // Check if ImageMagick is available
1162
+ try {
1163
+ execSync('which convert', { stdio: 'ignore' });
1164
+ } catch {
1165
+ throw new Error(
1166
+ `Cannot convert ${sourceFormat} to ${targetFormat}: ImageMagick not available`
1167
+ );
1168
+ }
1169
+
1170
+ // Create temp directory and files
1171
+ const tempDir = mkdtempSync(join(tmpdir(), 'ipp-convert-'));
1172
+ const sourceExt = this.formatToExtension(sourceFormat);
1173
+ const targetExt = this.formatToExtension(targetFormat);
1174
+ const sourcePath = join(tempDir, `input${sourceExt}`);
1175
+ const targetPath = join(tempDir, `output${targetExt}`);
1176
+
1177
+ try {
1178
+ // Write source file
1179
+ writeFileSync(sourcePath, data);
1180
+
1181
+ // Build convert command
1182
+ const args = [
1183
+ sourcePath,
1184
+ '-density', '300', // Good DPI for print
1185
+ '-quality', '90', // JPEG quality
1186
+ '-background', 'white', // White background for transparency
1187
+ '-flatten', // Flatten layers
1188
+ targetPath,
1189
+ ];
1190
+
1191
+ // For multi-page PDFs, only convert first page
1192
+ if (sourceFormat === 'application/pdf') {
1193
+ args[0] = `${sourcePath}[0]`; // First page only
1194
+ }
1195
+
1196
+ // Run conversion
1197
+ const result = spawnSync('convert', args, {
1198
+ timeout: 30000,
1199
+ maxBuffer: 100 * 1024 * 1024, // 100MB
1200
+ });
1201
+
1202
+ if (result.status !== 0) {
1203
+ const stderr = result.stderr?.toString() || 'Unknown error';
1204
+ throw new Error(`ImageMagick conversion failed: ${stderr}`);
1205
+ }
1206
+
1207
+ // Read converted file
1208
+ return readFileSync(targetPath);
1209
+
1210
+ } finally {
1211
+ // Cleanup temp files
1212
+ try { unlinkSync(sourcePath); } catch {}
1213
+ try { unlinkSync(targetPath); } catch {}
1214
+ try { const { rmdirSync } = await import('fs'); rmdirSync(tempDir); } catch {}
1215
+ }
1216
+ }
1217
+
1218
+ /**
1219
+ * Get file extension for MIME type
1220
+ */
1221
+ private formatToExtension(mimeType: string): string {
1222
+ const extensions: Record<string, string> = {
1223
+ 'application/pdf': '.pdf',
1224
+ 'image/jpeg': '.jpg',
1225
+ 'image/png': '.png',
1226
+ 'image/tiff': '.tiff',
1227
+ 'application/postscript': '.ps',
1228
+ 'image/pwg-raster': '.pwg',
1229
+ 'image/urf': '.urf',
1230
+ 'application/octet-stream': '.bin',
327
1231
  };
1232
+ return extensions[mimeType] || '.bin';
328
1233
  }
1234
+
329
1235
  }
1236
+
1237
+ // Export as IppProtocol for consistency with other protocols
1238
+ export { IppClient as IppProtocol };
1239
+
1240
+ // ============================================================================
1241
+ // Exports
1242
+ // ============================================================================
1243
+
1244
+ export {
1245
+ IPP_VERSION,
1246
+ IPP_OPERATION,
1247
+ IPP_STATUS,
1248
+ JOB_STATE,
1249
+ PRINTER_STATE,
1250
+ isSuccessful,
1251
+ statusCodeToString,
1252
+ };