initial
This commit is contained in:
370
ts/scanner/scanner.classes.scanner.ts
Normal file
370
ts/scanner/scanner.classes.scanner.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user