371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
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<string, string>;
|
|
},
|
|
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<string, string>): 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<string, string>): 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<string, string>): 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<string, string>): 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<IScannerCapabilities> {
|
|
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<IScanResult> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
if (this.esclClient) {
|
|
this.esclClient = null;
|
|
}
|
|
|
|
if (this.saneClient) {
|
|
await this.saneClient.disconnect();
|
|
this.saneClient = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh scanner status
|
|
*/
|
|
public async refreshStatus(): Promise<void> {
|
|
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 };
|