import * as plugins from '../plugins.js'; import { Device } from '../abstract/device.abstract.js'; import { EsclProtocol } from './scanner.classes.esclprotocol.js'; import { SaneProtocol } from './scanner.classes.saneprotocol.js'; import type { IScannerInfo, IScannerCapabilities, IScanOptions, IScanResult, TScannerProtocol, TScanFormat, TColorMode, TScanSource, IRetryOptions, } from '../interfaces/index.js'; /** * Unified Scanner class that abstracts over eSCL and SANE protocols */ export class Scanner extends Device { public readonly protocol: TScannerProtocol; public supportedFormats: TScanFormat[] = ['jpeg', 'png', 'pdf']; public supportedResolutions: number[] = [75, 150, 300, 600]; public supportedColorModes: TColorMode[] = ['color', 'grayscale', 'blackwhite']; public supportedSources: TScanSource[] = ['flatbed']; public hasAdf: boolean = false; public hasDuplex: boolean = false; public maxWidth: number = 215.9; // A4 width in mm public maxHeight: number = 297; // A4 height in mm private esclClient: EsclProtocol | null = null; private saneClient: SaneProtocol | null = null; private deviceName: string = ''; private isSecure: boolean = false; constructor( info: IScannerInfo, options?: { deviceName?: string; secure?: boolean; retryOptions?: IRetryOptions; } ) { super(info, options?.retryOptions); this.protocol = info.protocol; this.supportedFormats = info.supportedFormats; this.supportedResolutions = info.supportedResolutions; this.supportedColorModes = info.supportedColorModes; this.supportedSources = info.supportedSources; this.hasAdf = info.hasAdf; this.hasDuplex = info.hasDuplex; this.maxWidth = info.maxWidth ?? this.maxWidth; this.maxHeight = info.maxHeight ?? this.maxHeight; this.deviceName = options?.deviceName ?? ''; this.isSecure = options?.secure ?? false; } /** * Create a Scanner from discovery info */ public static fromDiscovery( discoveredDevice: { id: string; name: string; address: string; port: number; protocol: TScannerProtocol | 'ipp'; txtRecords: Record; }, retryOptions?: IRetryOptions ): Scanner { const protocol = discoveredDevice.protocol === 'ipp' ? 'escl' : discoveredDevice.protocol; // Parse capabilities from TXT records const formats = Scanner.parseFormats(discoveredDevice.txtRecords); const resolutions = Scanner.parseResolutions(discoveredDevice.txtRecords); const colorModes = Scanner.parseColorModes(discoveredDevice.txtRecords); const sources = Scanner.parseSources(discoveredDevice.txtRecords); const info: IScannerInfo = { id: discoveredDevice.id, name: discoveredDevice.name, type: 'scanner', address: discoveredDevice.address, port: discoveredDevice.port, status: 'online', protocol: protocol, supportedFormats: formats, supportedResolutions: resolutions, supportedColorModes: colorModes, supportedSources: sources, hasAdf: sources.includes('adf') || sources.includes('adf-duplex'), hasDuplex: sources.includes('adf-duplex'), manufacturer: discoveredDevice.txtRecords['usb_MFG'] || discoveredDevice.txtRecords['mfg'], model: discoveredDevice.txtRecords['usb_MDL'] || discoveredDevice.txtRecords['mdl'] || discoveredDevice.txtRecords['ty'], }; const isSecure = discoveredDevice.txtRecords['TLS'] === '1' || discoveredDevice.protocol === 'escl' && discoveredDevice.port === 443; return new Scanner(info, { secure: isSecure, retryOptions, }); } /** * Parse supported formats from TXT records */ private static parseFormats(txtRecords: Record): TScanFormat[] { const formats: TScanFormat[] = []; const pdl = txtRecords['pdl'] || txtRecords['DocumentFormat'] || ''; if (pdl.includes('jpeg') || pdl.includes('jpg')) formats.push('jpeg'); if (pdl.includes('png')) formats.push('png'); if (pdl.includes('pdf')) formats.push('pdf'); // Default to jpeg if nothing found if (formats.length === 0) { formats.push('jpeg', 'png'); } return formats; } /** * Parse supported resolutions from TXT records */ private static parseResolutions(txtRecords: Record): number[] { const rs = txtRecords['rs'] || ''; const resolutions: number[] = []; // Try to parse comma-separated resolutions const parts = rs.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n > 0); if (parts.length > 0) { return parts; } // Default common resolutions return [75, 150, 300, 600]; } /** * Parse color modes from TXT records */ private static parseColorModes(txtRecords: Record): TColorMode[] { const cs = txtRecords['cs'] || txtRecords['ColorSpace'] || ''; const modes: TColorMode[] = []; if (cs.includes('color') || cs.includes('RGB')) modes.push('color'); if (cs.includes('gray') || cs.includes('grayscale')) modes.push('grayscale'); if (cs.includes('binary') || cs.includes('bw')) modes.push('blackwhite'); // Default to color and grayscale if (modes.length === 0) { modes.push('color', 'grayscale'); } return modes; } /** * Parse input sources from TXT records */ private static parseSources(txtRecords: Record): TScanSource[] { const is = txtRecords['is'] || txtRecords['InputSource'] || ''; const sources: TScanSource[] = []; if (is.includes('platen') || is.includes('flatbed') || is === '') { sources.push('flatbed'); } if (is.includes('adf') || is.includes('feeder')) { sources.push('adf'); } if (is.includes('duplex')) { sources.push('adf-duplex'); } // Default to flatbed if (sources.length === 0) { sources.push('flatbed'); } return sources; } /** * Get scanner info */ public getScannerInfo(): IScannerInfo { return { ...this.getInfo(), type: 'scanner', protocol: this.protocol, supportedFormats: this.supportedFormats, supportedResolutions: this.supportedResolutions, supportedColorModes: this.supportedColorModes, supportedSources: this.supportedSources, hasAdf: this.hasAdf, hasDuplex: this.hasDuplex, maxWidth: this.maxWidth, maxHeight: this.maxHeight, }; } /** * Get scanner capabilities */ public async getCapabilities(): Promise { if (!this.isConnected) { await this.connect(); } if (this.protocol === 'escl' && this.esclClient) { const caps = await this.esclClient.getCapabilities(); const platen = caps.platen; return { resolutions: platen?.supportedResolutions ?? this.supportedResolutions, formats: this.supportedFormats, colorModes: this.supportedColorModes, sources: this.supportedSources, maxWidth: platen ? platen.maxWidth / 300 * 25.4 : this.maxWidth, maxHeight: platen ? platen.maxHeight / 300 * 25.4 : this.maxHeight, minWidth: platen ? platen.minWidth / 300 * 25.4 : 0, minHeight: platen ? platen.minHeight / 300 * 25.4 : 0, }; } // Return defaults for SANE (would need to query options) return { resolutions: this.supportedResolutions, formats: this.supportedFormats, colorModes: this.supportedColorModes, sources: this.supportedSources, maxWidth: this.maxWidth, maxHeight: this.maxHeight, minWidth: 0, minHeight: 0, }; } /** * Perform a scan */ public async scan(options?: IScanOptions): Promise { if (!this.isConnected) { await this.connect(); } const scanOptions: IScanOptions = { resolution: options?.resolution ?? 300, format: options?.format ?? 'jpeg', colorMode: options?.colorMode ?? 'color', source: options?.source ?? 'flatbed', area: options?.area, intent: options?.intent ?? 'document', quality: options?.quality ?? 85, }; this.setStatus('busy'); this.emit('scan:started', scanOptions); try { let result: IScanResult; if (this.protocol === 'escl' && this.esclClient) { result = await this.withRetry(() => this.esclClient!.scan(scanOptions)); } else if (this.protocol === 'sane' && this.saneClient) { result = await this.withRetry(() => this.saneClient!.scan(scanOptions)); } else { throw new Error(`No protocol client available for ${this.protocol}`); } this.setStatus('online'); this.emit('scan:completed', result); return result; } catch (error) { this.setStatus('online'); this.emit('scan:error', error); throw error; } } /** * Cancel an ongoing scan */ public async cancelScan(): Promise { if (this.protocol === 'sane' && this.saneClient) { await this.saneClient.cancel(); } // eSCL cancellation is handled via job deletion in the protocol this.emit('scan:canceled'); } /** * Connect to the scanner */ protected async doConnect(): Promise { if (this.protocol === 'escl') { this.esclClient = new EsclProtocol(this.address, this.port, this.isSecure); // Test connection by getting capabilities await this.esclClient.getCapabilities(); } else if (this.protocol === 'sane') { this.saneClient = new SaneProtocol(this.address, this.port); await this.saneClient.connect(); // Get available devices const devices = await this.saneClient.getDevices(); if (devices.length === 0) { throw new Error('No SANE devices available'); } // Open the first device or the specified one const deviceToOpen = this.deviceName || devices[0].name; await this.saneClient.open(deviceToOpen); } else { throw new Error(`Unsupported protocol: ${this.protocol}`); } } /** * Disconnect from the scanner */ protected async doDisconnect(): Promise { if (this.esclClient) { this.esclClient = null; } if (this.saneClient) { await this.saneClient.disconnect(); this.saneClient = null; } } /** * Refresh scanner status */ public async refreshStatus(): Promise { try { if (this.protocol === 'escl' && this.esclClient) { const status = await this.esclClient.getStatus(); switch (status.state) { case 'Idle': this.setStatus('online'); break; case 'Processing': this.setStatus('busy'); break; case 'Stopped': case 'Testing': this.setStatus('offline'); break; } } else if (this.protocol === 'sane') { // SANE doesn't have a direct status query // Just check if we can still communicate if (this.saneClient) { await this.saneClient.getParameters(); this.setStatus('online'); } } } catch (error) { this.setStatus('error'); throw error; } } } export { EsclProtocol, SaneProtocol };