Files
devicemanager/ts/protocols/protocol.ipp.ts

1253 lines
38 KiB
TypeScript
Raw Permalink Normal View History

2026-01-09 07:14:39 +00:00
/**
* Clean IPP Protocol Implementation
* RFC 8010 (Encoding) and RFC 8011 (Model)
*
* This is a from-scratch implementation with proper capability detection,
* document format negotiation, and clean attribute parsing.
2026-01-09 07:14:39 +00:00
*/
// ============================================================================
// Constants
// ============================================================================
/** IPP Version */
const IPP_VERSION = {
V1_1: 0x0101,
V2_0: 0x0200,
} as const;
/** IPP Operations */
const IPP_OPERATION = {
PRINT_JOB: 0x0002,
VALIDATE_JOB: 0x0004,
CANCEL_JOB: 0x0008,
GET_JOB_ATTRIBUTES: 0x0009,
GET_JOBS: 0x000a,
GET_PRINTER_ATTRIBUTES: 0x000b,
} as const;
/** IPP Status Codes */
const IPP_STATUS = {
// Successful
SUCCESSFUL_OK: 0x0000,
SUCCESSFUL_OK_IGNORED_ATTRIBUTES: 0x0001,
SUCCESSFUL_OK_CONFLICTING_ATTRIBUTES: 0x0002,
// Client errors
CLIENT_ERROR_BAD_REQUEST: 0x0400,
CLIENT_ERROR_FORBIDDEN: 0x0401,
CLIENT_ERROR_NOT_AUTHENTICATED: 0x0402,
CLIENT_ERROR_NOT_AUTHORIZED: 0x0403,
CLIENT_ERROR_NOT_POSSIBLE: 0x0404,
CLIENT_ERROR_TIMEOUT: 0x0405,
CLIENT_ERROR_NOT_FOUND: 0x0406,
CLIENT_ERROR_DOCUMENT_FORMAT_NOT_SUPPORTED: 0x040a,
// Server errors
SERVER_ERROR_INTERNAL: 0x0500,
SERVER_ERROR_OPERATION_NOT_SUPPORTED: 0x0501,
SERVER_ERROR_SERVICE_UNAVAILABLE: 0x0502,
SERVER_ERROR_DEVICE_ERROR: 0x0504,
SERVER_ERROR_NOT_ACCEPTING_JOBS: 0x0506,
SERVER_ERROR_BUSY: 0x0507,
} as const;
/** Delimiter Tags */
const TAG_DELIMITER = {
OPERATION_ATTRIBUTES: 0x01,
JOB_ATTRIBUTES: 0x02,
END_OF_ATTRIBUTES: 0x03,
PRINTER_ATTRIBUTES: 0x04,
UNSUPPORTED_ATTRIBUTES: 0x05,
} as const;
/** Value Tags */
const TAG_VALUE = {
// Out-of-band
UNSUPPORTED: 0x10,
UNKNOWN: 0x12,
NO_VALUE: 0x13,
// Integer
INTEGER: 0x21,
BOOLEAN: 0x22,
ENUM: 0x23,
// Octet string
OCTET_STRING: 0x30,
DATE_TIME: 0x31,
RESOLUTION: 0x32,
RANGE_OF_INTEGER: 0x33,
BEG_COLLECTION: 0x34,
TEXT_WITH_LANGUAGE: 0x35,
NAME_WITH_LANGUAGE: 0x36,
END_COLLECTION: 0x37,
// Character string
TEXT_WITHOUT_LANGUAGE: 0x41,
NAME_WITHOUT_LANGUAGE: 0x42,
KEYWORD: 0x44,
URI: 0x45,
URI_SCHEME: 0x46,
CHARSET: 0x47,
NATURAL_LANGUAGE: 0x48,
MIME_MEDIA_TYPE: 0x49,
MEMBER_ATTR_NAME: 0x4a,
} as const;
/** Job States */
const JOB_STATE = {
PENDING: 3,
PENDING_HELD: 4,
PROCESSING: 5,
PROCESSING_STOPPED: 6,
CANCELED: 7,
ABORTED: 8,
COMPLETED: 9,
} as const;
/** Printer States */
const PRINTER_STATE = {
IDLE: 3,
PROCESSING: 4,
STOPPED: 5,
} as const;
// ============================================================================
// Types
// ============================================================================
type TIppValue = string | number | boolean | Date | TIppResolution | TIppRange | TIppValue[];
interface TIppResolution {
crossFeed: number;
feed: number;
units: 'dpi' | 'dpcm';
}
interface TIppRange {
lower: number;
upper: number;
}
interface IIppAttribute {
tag: number;
name: string;
value: TIppValue;
}
interface IIppMessage {
version: number;
operationIdOrStatusCode: number;
requestId: number;
operationAttributes: Record<string, TIppValue>;
jobAttributes?: Record<string, TIppValue>;
printerAttributes?: Record<string, TIppValue>;
unsupportedAttributes?: Record<string, TIppValue>;
data?: Buffer;
}
export interface IIppPrinterCapabilities {
// Basic info
printerName: string;
printerInfo?: string;
printerMakeAndModel?: string;
printerLocation?: string;
printerUri: string;
// State
printerState: 'idle' | 'processing' | 'stopped';
printerStateReasons: string[];
printerIsAcceptingJobs: boolean;
queuedJobCount: number;
// Document formats
documentFormatSupported: string[];
documentFormatDefault?: string;
// Media
mediaSizeSupported: string[];
mediaDefault?: string;
mediaTypeSupported: string[];
// Capabilities
colorSupported: boolean;
sidesSupported: string[];
sidesDefault?: string;
copiesSupported: TIppRange;
printQualitySupported: number[];
resolutionsSupported: TIppResolution[];
// Additional
operationsSupported: number[];
ippVersionsSupported: string[];
}
export interface IIppPrintOptions {
jobName?: string;
requestingUserName?: string;
documentFormat?: string;
copies?: number;
sides?: 'one-sided' | 'two-sided-long-edge' | 'two-sided-short-edge';
media?: string;
printQuality?: 'draft' | 'normal' | 'high';
colorMode?: 'monochrome' | 'color' | 'auto';
orientation?: 'portrait' | 'landscape' | 'reverse-landscape' | 'reverse-portrait';
}
export interface IIppJob {
id: number;
uri: string;
state: 'pending' | 'pending-held' | 'processing' | 'processing-stopped' | 'canceled' | 'aborted' | 'completed';
stateReasons: string[];
name: string;
originatingUserName?: string;
createdAt?: Date;
completedAt?: Date;
processingAt?: Date;
impressionsCompleted?: number;
}
// ============================================================================
// IPP Message Encoder
// ============================================================================
class IppEncoder {
private buffer: number[] = [];
public encode(message: IIppMessage): Buffer {
this.buffer = [];
// Version
this.writeInt16(message.version);
// Operation ID or Status Code
this.writeInt16(message.operationIdOrStatusCode);
// Request ID
this.writeInt32(message.requestId);
// Operation attributes (always required)
this.writeDelimiter(TAG_DELIMITER.OPERATION_ATTRIBUTES);
this.writeAttributes(message.operationAttributes);
// Job attributes (optional)
if (message.jobAttributes && Object.keys(message.jobAttributes).length > 0) {
this.writeDelimiter(TAG_DELIMITER.JOB_ATTRIBUTES);
this.writeAttributes(message.jobAttributes);
}
// End of attributes
this.writeDelimiter(TAG_DELIMITER.END_OF_ATTRIBUTES);
// Data (for Print-Job)
const headerBuffer = Buffer.from(this.buffer);
if (message.data) {
return Buffer.concat([headerBuffer, message.data]);
}
return headerBuffer;
2026-01-09 07:14:39 +00:00
}
private writeInt8(value: number): void {
this.buffer.push(value & 0xff);
2026-01-09 07:14:39 +00:00
}
private writeInt16(value: number): void {
this.buffer.push((value >> 8) & 0xff);
this.buffer.push(value & 0xff);
2026-01-09 07:14:39 +00:00
}
private writeInt32(value: number): void {
this.buffer.push((value >> 24) & 0xff);
this.buffer.push((value >> 16) & 0xff);
this.buffer.push((value >> 8) & 0xff);
this.buffer.push(value & 0xff);
2026-01-09 07:14:39 +00:00
}
private writeString(value: string): void {
const bytes = Buffer.from(value, 'utf-8');
this.writeInt16(bytes.length);
for (const byte of bytes) {
this.buffer.push(byte);
}
2026-01-09 07:14:39 +00:00
}
private writeDelimiter(tag: number): void {
this.writeInt8(tag);
2026-01-09 07:14:39 +00:00
}
private writeAttributes(attrs: Record<string, TIppValue>): void {
for (const [name, value] of Object.entries(attrs)) {
this.writeAttribute(name, value);
2026-01-09 07:14:39 +00:00
}
}
private writeAttribute(name: string, value: TIppValue): void {
if (Array.isArray(value)) {
// Multi-value attribute
for (let i = 0; i < value.length; i++) {
this.writeSingleAttribute(i === 0 ? name : '', value[i], name);
}
} else {
this.writeSingleAttribute(name, value, name);
}
}
private writeSingleAttribute(name: string, value: TIppValue, originalName?: string): void {
const tag = this.inferTag(originalName || name, value);
this.writeInt8(tag);
// Name
const nameBytes = Buffer.from(name, 'utf-8');
this.writeInt16(nameBytes.length);
for (const byte of nameBytes) {
this.buffer.push(byte);
}
// Value
this.writeValue(tag, value);
}
private inferTag(name: string, value: TIppValue): number {
// IPP requires specific tags for certain attributes
const knownTags: Record<string, number> = {
// Operation attributes
'attributes-charset': TAG_VALUE.CHARSET,
'attributes-natural-language': TAG_VALUE.NATURAL_LANGUAGE,
'printer-uri': TAG_VALUE.URI,
'job-uri': TAG_VALUE.URI,
'job-name': TAG_VALUE.NAME_WITHOUT_LANGUAGE,
'requesting-user-name': TAG_VALUE.NAME_WITHOUT_LANGUAGE,
'document-name': TAG_VALUE.NAME_WITHOUT_LANGUAGE,
'document-format': TAG_VALUE.MIME_MEDIA_TYPE,
// Job attributes - keywords
'media': TAG_VALUE.KEYWORD,
'media-type': TAG_VALUE.KEYWORD,
'sides': TAG_VALUE.KEYWORD,
'print-color-mode': TAG_VALUE.KEYWORD,
'output-bin': TAG_VALUE.KEYWORD,
'which-jobs': TAG_VALUE.KEYWORD,
// Job attributes - enums (not integers!)
'print-quality': TAG_VALUE.ENUM,
'orientation-requested': TAG_VALUE.ENUM,
'finishings': TAG_VALUE.ENUM,
// Job attributes - integers
'copies': TAG_VALUE.INTEGER,
'job-id': TAG_VALUE.INTEGER,
'job-priority': TAG_VALUE.INTEGER,
2026-01-09 07:14:39 +00:00
};
if (knownTags[name]) {
return knownTags[name];
}
2026-01-09 07:14:39 +00:00
// Infer from value type
if (typeof value === 'boolean') return TAG_VALUE.BOOLEAN;
if (typeof value === 'number') return TAG_VALUE.INTEGER;
if (value instanceof Date) return TAG_VALUE.DATE_TIME;
if (typeof value === 'object' && 'crossFeed' in value) return TAG_VALUE.RESOLUTION;
if (typeof value === 'object' && 'lower' in value) return TAG_VALUE.RANGE_OF_INTEGER;
// String - try to infer type
const str = String(value);
if (str.startsWith('ipp://') || str.startsWith('ipps://') || str.startsWith('http://')) {
return TAG_VALUE.URI;
2026-01-09 07:14:39 +00:00
}
if (str.includes('/')) return TAG_VALUE.MIME_MEDIA_TYPE; // e.g., application/pdf
if (str.match(/^[a-z][a-z0-9-]*$/)) return TAG_VALUE.KEYWORD;
return TAG_VALUE.NAME_WITHOUT_LANGUAGE;
}
private writeValue(tag: number, value: TIppValue): void {
switch (tag) {
case TAG_VALUE.INTEGER:
case TAG_VALUE.ENUM:
this.writeInt16(4);
this.writeInt32(value as number);
break;
2026-01-09 07:14:39 +00:00
case TAG_VALUE.BOOLEAN:
this.writeInt16(1);
this.writeInt8(value ? 1 : 0);
break;
case TAG_VALUE.RESOLUTION: {
const res = value as TIppResolution;
this.writeInt16(9);
this.writeInt32(res.crossFeed);
this.writeInt32(res.feed);
this.writeInt8(res.units === 'dpi' ? 3 : 4);
break;
}
case TAG_VALUE.RANGE_OF_INTEGER: {
const range = value as TIppRange;
this.writeInt16(8);
this.writeInt32(range.lower);
this.writeInt32(range.upper);
break;
}
case TAG_VALUE.DATE_TIME: {
const date = value as Date;
this.writeInt16(11);
this.writeInt16(date.getUTCFullYear());
this.writeInt8(date.getUTCMonth() + 1);
this.writeInt8(date.getUTCDate());
this.writeInt8(date.getUTCHours());
this.writeInt8(date.getUTCMinutes());
this.writeInt8(date.getUTCSeconds());
this.writeInt8(0); // deciseconds
this.writeInt8('+'.charCodeAt(0));
this.writeInt8(0); // UTC offset hours
this.writeInt8(0); // UTC offset minutes
break;
}
default:
// String types
this.writeString(String(value));
break;
2026-01-09 07:14:39 +00:00
}
}
}
// ============================================================================
// IPP Message Decoder
// ============================================================================
2026-01-09 07:14:39 +00:00
class IppDecoder {
private buffer: Buffer = Buffer.alloc(0);
private offset: number = 0;
public decode(data: Buffer): IIppMessage {
this.buffer = data;
this.offset = 0;
const version = this.readInt16();
const operationIdOrStatusCode = this.readInt16();
const requestId = this.readInt32();
const message: IIppMessage = {
version,
operationIdOrStatusCode,
requestId,
operationAttributes: {},
};
// Read attribute groups
while (this.offset < this.buffer.length) {
const tag = this.readInt8();
if (tag === TAG_DELIMITER.END_OF_ATTRIBUTES) {
// Remaining data is document data
if (this.offset < this.buffer.length) {
message.data = this.buffer.subarray(this.offset);
}
break;
}
if (tag === TAG_DELIMITER.OPERATION_ATTRIBUTES) {
message.operationAttributes = this.readAttributes();
} else if (tag === TAG_DELIMITER.JOB_ATTRIBUTES) {
message.jobAttributes = this.readAttributes();
} else if (tag === TAG_DELIMITER.PRINTER_ATTRIBUTES) {
message.printerAttributes = this.readAttributes();
} else if (tag === TAG_DELIMITER.UNSUPPORTED_ATTRIBUTES) {
message.unsupportedAttributes = this.readAttributes();
}
2026-01-09 07:14:39 +00:00
}
return message;
}
private readInt8(): number {
return this.buffer[this.offset++];
}
private readInt16(): number {
const value = this.buffer.readUInt16BE(this.offset);
this.offset += 2;
return value;
}
private readInt32(): number {
const value = this.buffer.readInt32BE(this.offset);
this.offset += 4;
return value;
}
private readUInt32(): number {
const value = this.buffer.readUInt32BE(this.offset);
this.offset += 4;
return value;
}
private readString(length: number): string {
const str = this.buffer.toString('utf-8', this.offset, this.offset + length);
this.offset += length;
return str;
}
private readAttributes(): Record<string, TIppValue> {
const attrs: Record<string, TIppValue> = {};
let currentName = '';
while (this.offset < this.buffer.length) {
const tag = this.buffer[this.offset];
// Check for delimiter tag (start of new group or end)
if (tag <= 0x0f) {
break;
}
this.offset++; // Consume the tag
const nameLength = this.readInt16();
const name = nameLength > 0 ? this.readString(nameLength) : currentName;
const valueLength = this.readInt16();
const value = this.readValue(tag, valueLength);
if (nameLength > 0) {
currentName = name;
attrs[name] = value;
} else {
// Additional value for same attribute - make it an array
const existing = attrs[currentName];
if (Array.isArray(existing)) {
existing.push(value);
} else {
attrs[currentName] = [existing, value];
}
}
2026-01-09 07:14:39 +00:00
}
return attrs;
}
private readValue(tag: number, length: number): TIppValue {
switch (tag) {
case TAG_VALUE.INTEGER:
case TAG_VALUE.ENUM:
return this.readInt32();
case TAG_VALUE.BOOLEAN:
return this.readInt8() !== 0;
case TAG_VALUE.RESOLUTION: {
const crossFeed = this.readInt32();
const feed = this.readInt32();
const units = this.readInt8() === 3 ? 'dpi' : 'dpcm';
return { crossFeed, feed, units } as TIppResolution;
}
case TAG_VALUE.RANGE_OF_INTEGER: {
const lower = this.readInt32();
const upper = this.readInt32();
return { lower, upper } as TIppRange;
}
case TAG_VALUE.DATE_TIME: {
const year = this.readInt16();
const month = this.readInt8();
const day = this.readInt8();
const hour = this.readInt8();
const minute = this.readInt8();
const second = this.readInt8();
this.readInt8(); // deciseconds
const direction = String.fromCharCode(this.readInt8());
const offsetHours = this.readInt8();
const offsetMinutes = this.readInt8();
const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
const offsetMs = (offsetHours * 60 + offsetMinutes) * 60 * 1000;
if (direction === '-') {
date.setTime(date.getTime() + offsetMs);
} else {
date.setTime(date.getTime() - offsetMs);
}
return date;
}
case TAG_VALUE.NO_VALUE:
case TAG_VALUE.UNKNOWN:
case TAG_VALUE.UNSUPPORTED:
this.offset += length;
return '';
default:
// String types
return this.readString(length);
2026-01-09 07:14:39 +00:00
}
}
}
2026-01-09 07:14:39 +00:00
// ============================================================================
// Status Code Helpers
// ============================================================================
function isSuccessful(statusCode: number): boolean {
return statusCode >= 0x0000 && statusCode <= 0x00ff;
}
function statusCodeToString(code: number): string {
const statusMap: Record<number, string> = {
0x0000: 'successful-ok',
0x0001: 'successful-ok-ignored-or-substituted-attributes',
0x0002: 'successful-ok-conflicting-attributes',
0x0400: 'client-error-bad-request',
0x0401: 'client-error-forbidden',
0x0402: 'client-error-not-authenticated',
0x0403: 'client-error-not-authorized',
0x0404: 'client-error-not-possible',
0x0405: 'client-error-timeout',
0x0406: 'client-error-not-found',
0x040a: 'client-error-document-format-not-supported',
0x040b: 'client-error-attributes-or-values-not-supported',
0x0500: 'server-error-internal-error',
0x0501: 'server-error-operation-not-supported',
0x0502: 'server-error-service-unavailable',
0x0504: 'server-error-device-error',
0x0506: 'server-error-not-accepting-jobs',
0x0507: 'server-error-busy',
};
return statusMap[code] || `unknown-status-${code.toString(16)}`;
}
// ============================================================================
// IPP Client
// ============================================================================
export class IppClient {
private readonly printerUri: string;
private readonly httpUri: string;
private requestId: number = 1;
private readonly encoder = new IppEncoder();
private readonly decoder = new IppDecoder();
constructor(
address: string,
port: number = 631,
path: string = '/ipp/print'
) {
this.printerUri = `ipp://${address}:${port}${path}`;
this.httpUri = `http://${address}:${port}${path}`;
}
// ==========================================================================
// Core Request Method
// ==========================================================================
private async request(message: IIppMessage): Promise<IIppMessage> {
const requestData = this.encoder.encode(message);
const response = await fetch(this.httpUri, {
method: 'POST',
headers: {
'Content-Type': 'application/ipp',
'Accept': 'application/ipp',
},
body: new Uint8Array(requestData),
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
2026-01-09 07:14:39 +00:00
}
const responseData = Buffer.from(await response.arrayBuffer());
return this.decoder.decode(responseData);
}
// ==========================================================================
// Get Printer Attributes
// ==========================================================================
public async getPrinterAttributes(): Promise<IIppPrinterCapabilities> {
const message: IIppMessage = {
version: IPP_VERSION.V2_0,
operationIdOrStatusCode: IPP_OPERATION.GET_PRINTER_ATTRIBUTES,
requestId: this.requestId++,
operationAttributes: {
'attributes-charset': 'utf-8',
'attributes-natural-language': 'en-us',
'printer-uri': this.printerUri,
},
2026-01-09 07:14:39 +00:00
};
const response = await this.request(message);
if (!isSuccessful(response.operationIdOrStatusCode)) {
throw new Error(`IPP error: ${statusCodeToString(response.operationIdOrStatusCode)}`);
2026-01-09 07:14:39 +00:00
}
return this.parseCapabilities(response.printerAttributes || {});
2026-01-09 07:14:39 +00:00
}
private parseCapabilities(attrs: Record<string, TIppValue>): IIppPrinterCapabilities {
const getString = (key: string, defaultVal: string = ''): string => {
const val = attrs[key];
if (typeof val === 'string') return val;
if (Array.isArray(val) && val.length > 0) return String(val[0]);
return defaultVal;
};
const getStringArray = (key: string): string[] => {
const val = attrs[key];
if (Array.isArray(val)) return val.map(String);
if (val !== undefined) return [String(val)];
2026-01-09 07:14:39 +00:00
return [];
};
const getNumber = (key: string, defaultVal: number = 0): number => {
const val = attrs[key];
if (typeof val === 'number') return val;
2026-01-09 07:14:39 +00:00
return defaultVal;
};
const getNumberArray = (key: string): number[] => {
const val = attrs[key];
if (Array.isArray(val)) return val.filter((v): v is number => typeof v === 'number');
if (typeof val === 'number') return [val];
return [];
};
const getBool = (key: string, defaultVal: boolean = false): boolean => {
const val = attrs[key];
if (typeof val === 'boolean') return val;
2026-01-09 07:14:39 +00:00
return defaultVal;
};
const getRange = (key: string): TIppRange => {
const val = attrs[key];
if (val && typeof val === 'object' && 'lower' in val) {
return val as TIppRange;
2026-01-09 07:14:39 +00:00
}
return { lower: 1, upper: 1 };
};
const getResolutions = (): TIppResolution[] => {
const val = attrs['printer-resolution-supported'];
if (!val) return [];
const values = Array.isArray(val) ? val : [val];
return values
.filter((v): v is TIppResolution =>
typeof v === 'object' && v !== null && 'crossFeed' in v
);
};
// Map printer state number to string
const printerState = getNumber('printer-state', PRINTER_STATE.IDLE);
const printerStateString = printerState === PRINTER_STATE.IDLE ? 'idle' :
printerState === PRINTER_STATE.PROCESSING ? 'processing' : 'stopped';
2026-01-09 07:14:39 +00:00
return {
printerName: getString('printer-name', 'Unknown Printer'),
printerInfo: getString('printer-info') || undefined,
printerMakeAndModel: getString('printer-make-and-model') || undefined,
printerLocation: getString('printer-location') || undefined,
printerUri: this.printerUri,
printerState: printerStateString,
printerStateReasons: getStringArray('printer-state-reasons'),
printerIsAcceptingJobs: getBool('printer-is-accepting-jobs', true),
queuedJobCount: getNumber('queued-job-count', 0),
documentFormatSupported: getStringArray('document-format-supported'),
documentFormatDefault: getString('document-format-default') || undefined,
mediaSizeSupported: getStringArray('media-supported'),
mediaDefault: getString('media-default') || undefined,
mediaTypeSupported: getStringArray('media-type-supported'),
2026-01-09 07:14:39 +00:00
colorSupported: getBool('color-supported', false),
sidesSupported: getStringArray('sides-supported'),
sidesDefault: getString('sides-default') || undefined,
copiesSupported: getRange('copies-supported'),
printQualitySupported: getNumberArray('print-quality-supported'),
resolutionsSupported: getResolutions(),
operationsSupported: getNumberArray('operations-supported'),
ippVersionsSupported: getStringArray('ipp-versions-supported'),
2026-01-09 07:14:39 +00:00
};
}
// ==========================================================================
// Print Job
// ==========================================================================
public async printJob(
data: Buffer,
options: IIppPrintOptions = {}
): Promise<IIppJob> {
// Build operation attributes
const operationAttrs: Record<string, TIppValue> = {
'attributes-charset': 'utf-8',
'attributes-natural-language': 'en-us',
'printer-uri': this.printerUri,
'requesting-user-name': options.requestingUserName || 'devicemanager',
'job-name': options.jobName || 'Print Job',
'document-format': options.documentFormat || 'application/octet-stream',
};
// Build job attributes
const jobAttrs: Record<string, TIppValue> = {};
if (options.copies && options.copies > 1) {
jobAttrs['copies'] = options.copies;
}
if (options.sides) {
jobAttrs['sides'] = options.sides;
}
if (options.media) {
jobAttrs['media'] = options.media;
}
if (options.printQuality) {
const qualityMap = { draft: 3, normal: 4, high: 5 };
jobAttrs['print-quality'] = qualityMap[options.printQuality];
}
if (options.colorMode) {
jobAttrs['print-color-mode'] = options.colorMode;
}
if (options.orientation) {
const orientationMap = {
portrait: 3,
landscape: 4,
'reverse-landscape': 5,
'reverse-portrait': 6,
};
jobAttrs['orientation-requested'] = orientationMap[options.orientation];
}
const message: IIppMessage = {
version: IPP_VERSION.V2_0,
operationIdOrStatusCode: IPP_OPERATION.PRINT_JOB,
requestId: this.requestId++,
operationAttributes: operationAttrs,
jobAttributes: Object.keys(jobAttrs).length > 0 ? jobAttrs : undefined,
data,
};
const response = await this.request(message);
if (!isSuccessful(response.operationIdOrStatusCode)) {
throw new Error(`Print failed: ${statusCodeToString(response.operationIdOrStatusCode)}`);
}
return this.parseJob(response.jobAttributes || {});
}
// ==========================================================================
// Get Jobs
// ==========================================================================
public async getJobs(whichJobs: 'completed' | 'not-completed' | 'all' = 'not-completed'): Promise<IIppJob[]> {
const message: IIppMessage = {
version: IPP_VERSION.V2_0,
operationIdOrStatusCode: IPP_OPERATION.GET_JOBS,
requestId: this.requestId++,
operationAttributes: {
'attributes-charset': 'utf-8',
'attributes-natural-language': 'en-us',
'printer-uri': this.printerUri,
'requesting-user-name': 'devicemanager',
'which-jobs': whichJobs,
},
};
const response = await this.request(message);
if (!isSuccessful(response.operationIdOrStatusCode)) {
throw new Error(`Get jobs failed: ${statusCodeToString(response.operationIdOrStatusCode)}`);
}
// Jobs can come back as a single job-attributes-tag or multiple
// The decoder merges additional values into arrays
if (!response.jobAttributes) {
return [];
}
// If job-id is an array, we have multiple jobs encoded together
// This is a limitation of the simple decoder - in reality each job is a separate group
// For now, return single job if present
return [this.parseJob(response.jobAttributes)];
}
// ==========================================================================
// Get Job Attributes
// ==========================================================================
public async getJobAttributes(jobId: number): Promise<IIppJob> {
const message: IIppMessage = {
version: IPP_VERSION.V2_0,
operationIdOrStatusCode: IPP_OPERATION.GET_JOB_ATTRIBUTES,
requestId: this.requestId++,
operationAttributes: {
'attributes-charset': 'utf-8',
'attributes-natural-language': 'en-us',
'printer-uri': this.printerUri,
'job-id': jobId,
},
};
const response = await this.request(message);
if (!isSuccessful(response.operationIdOrStatusCode)) {
throw new Error(`Get job failed: ${statusCodeToString(response.operationIdOrStatusCode)}`);
}
return this.parseJob(response.jobAttributes || {});
}
// ==========================================================================
// Cancel Job
// ==========================================================================
public async cancelJob(jobId: number): Promise<void> {
const message: IIppMessage = {
version: IPP_VERSION.V2_0,
operationIdOrStatusCode: IPP_OPERATION.CANCEL_JOB,
requestId: this.requestId++,
operationAttributes: {
'attributes-charset': 'utf-8',
'attributes-natural-language': 'en-us',
'printer-uri': this.printerUri,
'job-id': jobId,
},
};
const response = await this.request(message);
if (!isSuccessful(response.operationIdOrStatusCode)) {
throw new Error(`Cancel job failed: ${statusCodeToString(response.operationIdOrStatusCode)}`);
}
}
// ==========================================================================
// Validate Job (check if job would succeed without actually printing)
// ==========================================================================
public async validateJob(options: IIppPrintOptions = {}): Promise<boolean> {
const operationAttrs: Record<string, TIppValue> = {
'attributes-charset': 'utf-8',
'attributes-natural-language': 'en-us',
'printer-uri': this.printerUri,
'document-format': options.documentFormat || 'application/octet-stream',
};
const message: IIppMessage = {
version: IPP_VERSION.V2_0,
operationIdOrStatusCode: IPP_OPERATION.VALIDATE_JOB,
requestId: this.requestId++,
operationAttributes: operationAttrs,
};
const response = await this.request(message);
return isSuccessful(response.operationIdOrStatusCode);
}
// ==========================================================================
// Helper Methods
// ==========================================================================
private parseJob(attrs: Record<string, TIppValue>): IIppJob {
const getNumber = (key: string, defaultVal: number = 0): number => {
const val = attrs[key];
if (typeof val === 'number') return val;
if (Array.isArray(val) && typeof val[0] === 'number') return val[0];
2026-01-09 07:14:39 +00:00
return defaultVal;
};
const getString = (key: string, defaultVal: string = ''): string => {
const val = attrs[key];
if (typeof val === 'string') return val;
if (Array.isArray(val) && val.length > 0) return String(val[0]);
2026-01-09 07:14:39 +00:00
return defaultVal;
};
const getStringArray = (key: string): string[] => {
const val = attrs[key];
if (Array.isArray(val)) return val.map(String);
if (val !== undefined) return [String(val)];
return [];
2026-01-09 07:14:39 +00:00
};
const getDate = (key: string): Date | undefined => {
const val = attrs[key];
if (val instanceof Date) return val;
return undefined;
};
// Map job state number to string
const jobState = getNumber('job-state', JOB_STATE.PENDING);
const jobStateMap: Record<number, IIppJob['state']> = {
[JOB_STATE.PENDING]: 'pending',
[JOB_STATE.PENDING_HELD]: 'pending-held',
[JOB_STATE.PROCESSING]: 'processing',
[JOB_STATE.PROCESSING_STOPPED]: 'processing-stopped',
[JOB_STATE.CANCELED]: 'canceled',
[JOB_STATE.ABORTED]: 'aborted',
[JOB_STATE.COMPLETED]: 'completed',
};
2026-01-09 07:14:39 +00:00
return {
id: getNumber('job-id'),
uri: getString('job-uri'),
state: jobStateMap[jobState] || 'pending',
stateReasons: getStringArray('job-state-reasons'),
2026-01-09 07:14:39 +00:00
name: getString('job-name', 'Unknown Job'),
originatingUserName: getString('job-originating-user-name') || undefined,
createdAt: getDate('time-at-creation') || getDate('date-time-at-creation'),
completedAt: getDate('time-at-completed') || getDate('date-time-at-completed'),
processingAt: getDate('time-at-processing') || getDate('date-time-at-processing'),
impressionsCompleted: getNumber('job-impressions-completed') || undefined,
};
}
// ==========================================================================
// Convenience: Check if format is supported
// ==========================================================================
public async isFormatSupported(mimeType: string): Promise<boolean> {
const caps = await this.getPrinterAttributes();
return caps.documentFormatSupported.includes(mimeType);
}
// ==========================================================================
// Convenience: Get best format for data
// ==========================================================================
public async getBestFormat(preferredFormats: string[]): Promise<string | null> {
const caps = await this.getPrinterAttributes();
const supported = caps.documentFormatSupported;
for (const format of preferredFormats) {
if (supported.includes(format)) {
return format;
}
}
return null;
}
// ==========================================================================
// Smart Print: Auto-detect format and convert if needed
// ==========================================================================
/**
* Smart print that auto-detects document format and converts if necessary.
*
* If the document format is not supported by the printer, it will attempt
* to convert to a supported format (e.g., PDF JPEG).
*/
public async smartPrint(
data: Buffer,
options: IIppPrintOptions = {}
): Promise<IIppJob> {
// Detect format from data
const detectedFormat = this.detectFormat(data);
console.log(`[IPP] Detected format: ${detectedFormat}`);
// Get printer capabilities
const caps = await this.getPrinterAttributes();
const supported = caps.documentFormatSupported;
// Check if detected format is supported
if (supported.includes(detectedFormat)) {
console.log(`[IPP] Format ${detectedFormat} is supported, printing directly`);
return this.printJob(data, {
...options,
documentFormat: detectedFormat,
});
}
// Format not supported - try to convert
console.log(`[IPP] Format ${detectedFormat} not supported, attempting conversion`);
// Determine best target format
const conversionTargets = ['image/jpeg', 'image/png', 'image/pwg-raster'];
const targetFormat = conversionTargets.find(f => supported.includes(f));
if (!targetFormat) {
throw new Error(
`Document format ${detectedFormat} not supported and no conversion target available. ` +
`Printer supports: ${supported.join(', ')}`
);
}
// Convert the document
const convertedData = await this.convertDocument(data, detectedFormat, targetFormat);
console.log(`[IPP] Converted to ${targetFormat} (${convertedData.length} bytes)`);
return this.printJob(convertedData, {
...options,
documentFormat: targetFormat,
});
}
/**
* Detect document format from magic bytes
*/
private detectFormat(data: Buffer): string {
if (data.length < 4) {
return 'application/octet-stream';
}
// PDF: starts with %PDF
if (data[0] === 0x25 && data[1] === 0x50 && data[2] === 0x44 && data[3] === 0x46) {
return 'application/pdf';
}
// JPEG: starts with FFD8FF
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
return 'image/jpeg';
}
// PNG: starts with 89504E47
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
return 'image/png';
}
// TIFF: starts with 49492A00 (little endian) or 4D4D002A (big endian)
if ((data[0] === 0x49 && data[1] === 0x49 && data[2] === 0x2a && data[3] === 0x00) ||
(data[0] === 0x4d && data[1] === 0x4d && data[2] === 0x00 && data[3] === 0x2a)) {
return 'image/tiff';
}
// PostScript: starts with %!
if (data[0] === 0x25 && data[1] === 0x21) {
return 'application/postscript';
}
// PWG Raster: starts with "RaS2"
if (data[0] === 0x52 && data[1] === 0x61 && data[2] === 0x53 && data[3] === 0x32) {
return 'image/pwg-raster';
}
// URF (Apple Raster): starts with "UNIRAST"
if (data.length >= 8 &&
data[0] === 0x55 && data[1] === 0x4e && data[2] === 0x49 && data[3] === 0x52 &&
data[4] === 0x41 && data[5] === 0x53 && data[6] === 0x54) {
return 'image/urf';
}
return 'application/octet-stream';
}
/**
* Convert document to target format
* Uses ImageMagick if available, otherwise throws
*/
private async convertDocument(
data: Buffer,
sourceFormat: string,
targetFormat: string
): Promise<Buffer> {
// Try using ImageMagick via child_process
const { execSync, spawnSync } = await import('child_process');
const { writeFileSync, readFileSync, unlinkSync, mkdtempSync } = await import('fs');
const { join } = await import('path');
const { tmpdir } = await import('os');
// Check if ImageMagick is available
try {
execSync('which convert', { stdio: 'ignore' });
} catch {
throw new Error(
`Cannot convert ${sourceFormat} to ${targetFormat}: ImageMagick not available`
);
}
// Create temp directory and files
const tempDir = mkdtempSync(join(tmpdir(), 'ipp-convert-'));
const sourceExt = this.formatToExtension(sourceFormat);
const targetExt = this.formatToExtension(targetFormat);
const sourcePath = join(tempDir, `input${sourceExt}`);
const targetPath = join(tempDir, `output${targetExt}`);
try {
// Write source file
writeFileSync(sourcePath, data);
// Build convert command
const args = [
sourcePath,
'-density', '300', // Good DPI for print
'-quality', '90', // JPEG quality
'-background', 'white', // White background for transparency
'-flatten', // Flatten layers
targetPath,
];
// For multi-page PDFs, only convert first page
if (sourceFormat === 'application/pdf') {
args[0] = `${sourcePath}[0]`; // First page only
}
// Run conversion
const result = spawnSync('convert', args, {
timeout: 30000,
maxBuffer: 100 * 1024 * 1024, // 100MB
});
if (result.status !== 0) {
const stderr = result.stderr?.toString() || 'Unknown error';
throw new Error(`ImageMagick conversion failed: ${stderr}`);
}
// Read converted file
return readFileSync(targetPath);
} finally {
// Cleanup temp files
try { unlinkSync(sourcePath); } catch {}
try { unlinkSync(targetPath); } catch {}
try { const { rmdirSync } = await import('fs'); rmdirSync(tempDir); } catch {}
}
}
/**
* Get file extension for MIME type
*/
private formatToExtension(mimeType: string): string {
const extensions: Record<string, string> = {
'application/pdf': '.pdf',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/tiff': '.tiff',
'application/postscript': '.ps',
'image/pwg-raster': '.pwg',
'image/urf': '.urf',
'application/octet-stream': '.bin',
2026-01-09 07:14:39 +00:00
};
return extensions[mimeType] || '.bin';
2026-01-09 07:14:39 +00:00
}
2026-01-09 07:14:39 +00:00
}
// Export as IppProtocol for consistency with other protocols
export { IppClient as IppProtocol };
// ============================================================================
// Exports
// ============================================================================
export {
IPP_VERSION,
IPP_OPERATION,
IPP_STATUS,
JOB_STATE,
PRINTER_STATE,
isSuccessful,
statusCodeToString,
};