feat(devicemanager): Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
This commit is contained in:
297
ts/features/feature.print.ts
Normal file
297
ts/features/feature.print.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Print Feature
|
||||
* Provides document printing capability using IPP protocol
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import { IppProtocol } from '../printer/printer.classes.ippprotocol.js';
|
||||
import type {
|
||||
TPrintProtocol,
|
||||
TPrintSides,
|
||||
TPrintQuality,
|
||||
TPrintColorMode,
|
||||
IPrintCapabilities,
|
||||
IPrintOptions,
|
||||
IPrintJob,
|
||||
IPrintFeatureInfo,
|
||||
IFeatureOptions,
|
||||
} from '../interfaces/feature.interfaces.js';
|
||||
import type { IPrinterCapabilities } from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Options for creating a PrintFeature
|
||||
*/
|
||||
export interface IPrintFeatureOptions extends IFeatureOptions {
|
||||
protocol?: TPrintProtocol;
|
||||
uri?: string;
|
||||
supportsColor?: boolean;
|
||||
supportsDuplex?: boolean;
|
||||
supportedMediaSizes?: string[];
|
||||
supportedMediaTypes?: string[];
|
||||
maxCopies?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print Feature - provides document printing capability
|
||||
*
|
||||
* Wraps the IPP protocol to provide a unified printing interface.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const printFeature = device.getFeature<PrintFeature>('print');
|
||||
* if (printFeature) {
|
||||
* await printFeature.connect();
|
||||
* const job = await printFeature.print(pdfBuffer, { copies: 2 });
|
||||
* console.log(`Print job ${job.id} created`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class PrintFeature extends Feature {
|
||||
public readonly type = 'print' as const;
|
||||
public readonly protocol: TPrintProtocol;
|
||||
|
||||
// Protocol client
|
||||
private ippClient: IppProtocol | null = null;
|
||||
|
||||
// Configuration
|
||||
public readonly uri: string;
|
||||
|
||||
// Capabilities
|
||||
public supportsColor: boolean = true;
|
||||
public supportsDuplex: boolean = false;
|
||||
public supportedMediaSizes: string[] = ['iso_a4_210x297mm', 'na_letter_8.5x11in'];
|
||||
public supportedMediaTypes: string[] = ['stationery'];
|
||||
public maxCopies: number = 99;
|
||||
public supportedSides: TPrintSides[] = ['one-sided'];
|
||||
public supportedQualities: TPrintQuality[] = ['normal'];
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options?: IPrintFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options?.protocol ?? 'ipp';
|
||||
this.uri = options?.uri ?? `ipp://${device.address}:${port}/ipp/print`;
|
||||
|
||||
// Set capabilities from options if provided
|
||||
if (options?.supportsColor !== undefined) this.supportsColor = options.supportsColor;
|
||||
if (options?.supportsDuplex !== undefined) {
|
||||
this.supportsDuplex = options.supportsDuplex;
|
||||
if (options.supportsDuplex) {
|
||||
this.supportedSides = ['one-sided', 'two-sided-long-edge', 'two-sided-short-edge'];
|
||||
}
|
||||
}
|
||||
if (options?.supportedMediaSizes) this.supportedMediaSizes = options.supportedMediaSizes;
|
||||
if (options?.supportedMediaTypes) this.supportedMediaTypes = options.supportedMediaTypes;
|
||||
if (options?.maxCopies) this.maxCopies = options.maxCopies;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
if (this.protocol === 'ipp') {
|
||||
// Parse URI to get address, port, and path
|
||||
const url = new URL(this.uri.replace('ipp://', 'http://').replace('ipps://', 'https://'));
|
||||
const address = url.hostname;
|
||||
const port = parseInt(url.port) || this._port;
|
||||
const path = url.pathname || '/ipp/print';
|
||||
|
||||
this.ippClient = new IppProtocol(address, port, path);
|
||||
// Verify connection by getting printer attributes
|
||||
const attrs = await this.ippClient.getAttributes();
|
||||
this.updateCapabilitiesFromIpp(attrs);
|
||||
}
|
||||
// JetDirect and LPD don't need connection verification
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
this.ippClient = null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Printing Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get printer capabilities
|
||||
*/
|
||||
public async getCapabilities(): Promise<IPrintCapabilities> {
|
||||
return {
|
||||
colorSupported: this.supportsColor,
|
||||
duplexSupported: this.supportsDuplex,
|
||||
mediaSizes: this.supportedMediaSizes,
|
||||
mediaTypes: this.supportedMediaTypes,
|
||||
resolutions: [300, 600],
|
||||
maxCopies: this.maxCopies,
|
||||
sidesSupported: this.supportedSides,
|
||||
qualitySupported: this.supportedQualities,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a document
|
||||
*/
|
||||
public async print(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Print feature not connected');
|
||||
}
|
||||
|
||||
if (this.protocol === 'ipp' && this.ippClient) {
|
||||
return this.printWithIpp(data, options);
|
||||
}
|
||||
|
||||
throw new Error(`Protocol ${this.protocol} not supported yet`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active print jobs
|
||||
*/
|
||||
public async getJobs(): Promise<IPrintJob[]> {
|
||||
if (!this.isConnected || !this.ippClient) {
|
||||
throw new Error('Print feature not connected');
|
||||
}
|
||||
|
||||
return this.ippClient.getJobs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get info about a specific job
|
||||
*/
|
||||
public async getJobInfo(jobId: number): Promise<IPrintJob> {
|
||||
if (!this.isConnected || !this.ippClient) {
|
||||
throw new Error('Print feature not connected');
|
||||
}
|
||||
|
||||
return this.ippClient.getJobInfo(jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a print job
|
||||
*/
|
||||
public async cancelJob(jobId: number): Promise<void> {
|
||||
if (!this.isConnected || !this.ippClient) {
|
||||
throw new Error('Print feature not connected');
|
||||
}
|
||||
|
||||
await this.ippClient.cancelJob(jobId);
|
||||
this.emit('job:cancelled', jobId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Protocol-Specific Printing
|
||||
// ============================================================================
|
||||
|
||||
private async printWithIpp(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
||||
if (!this.ippClient) {
|
||||
throw new Error('IPP client not initialized');
|
||||
}
|
||||
|
||||
this.emit('print:started', options);
|
||||
|
||||
// IppProtocol.print() accepts IPrintOptions and returns IPrintJob
|
||||
const job = await this.ippClient.print(data, options);
|
||||
|
||||
this.emit('print:submitted', job);
|
||||
return job;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Methods
|
||||
// ============================================================================
|
||||
|
||||
private updateCapabilitiesFromIpp(caps: IPrinterCapabilities): void {
|
||||
this.supportsColor = caps.colorSupported;
|
||||
this.supportsDuplex = caps.duplexSupported;
|
||||
this.maxCopies = caps.maxCopies;
|
||||
|
||||
if (caps.mediaSizes && caps.mediaSizes.length > 0) {
|
||||
this.supportedMediaSizes = caps.mediaSizes;
|
||||
}
|
||||
if (caps.mediaTypes && caps.mediaTypes.length > 0) {
|
||||
this.supportedMediaTypes = caps.mediaTypes;
|
||||
}
|
||||
if (caps.sidesSupported && caps.sidesSupported.length > 0) {
|
||||
this.supportedSides = caps.sidesSupported.filter((s): s is TPrintSides =>
|
||||
['one-sided', 'two-sided-long-edge', 'two-sided-short-edge'].includes(s)
|
||||
);
|
||||
}
|
||||
if (caps.qualitySupported && caps.qualitySupported.length > 0) {
|
||||
this.supportedQualities = caps.qualitySupported.filter((q): q is TPrintQuality =>
|
||||
['draft', 'normal', 'high'].includes(q)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private qualityToIpp(quality: TPrintQuality): number {
|
||||
switch (quality) {
|
||||
case 'draft': return 3;
|
||||
case 'normal': return 4;
|
||||
case 'high': return 5;
|
||||
default: return 4;
|
||||
}
|
||||
}
|
||||
|
||||
private mapIppJob(job: Record<string, unknown>): IPrintJob {
|
||||
const stateMap: Record<number, IPrintJob['state']> = {
|
||||
3: 'pending',
|
||||
4: 'pending',
|
||||
5: 'processing',
|
||||
6: 'processing',
|
||||
7: 'canceled',
|
||||
8: 'aborted',
|
||||
9: 'completed',
|
||||
};
|
||||
|
||||
return {
|
||||
id: job['job-id'] as number,
|
||||
name: job['job-name'] as string ?? 'Unknown',
|
||||
state: stateMap[(job['job-state'] as number) ?? 3] ?? 'pending',
|
||||
stateReason: (job['job-state-reasons'] as string[])?.[0],
|
||||
createdAt: new Date((job['time-at-creation'] as number) * 1000),
|
||||
completedAt: job['time-at-completed']
|
||||
? new Date((job['time-at-completed'] as number) * 1000)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Serialization
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): IPrintFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'print',
|
||||
protocol: this.protocol,
|
||||
supportsColor: this.supportsColor,
|
||||
supportsDuplex: this.supportsDuplex,
|
||||
supportedMediaSizes: this.supportedMediaSizes,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Static Factory
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create from discovery metadata
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
protocol: TPrintProtocol,
|
||||
metadata: Record<string, unknown>
|
||||
): PrintFeature {
|
||||
const txtRecords = metadata.txtRecords as Record<string, string> ?? {};
|
||||
|
||||
return new PrintFeature(device, port, {
|
||||
protocol,
|
||||
uri: metadata.uri as string,
|
||||
supportsColor: txtRecords['Color'] === 'T' || txtRecords['color'] === 'true',
|
||||
supportsDuplex: txtRecords['Duplex'] === 'T' || txtRecords['duplex'] === 'true',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user