Files
devicemanager/ts/scanner/scanner.classes.scanner.ts

371 lines
11 KiB
TypeScript
Raw Normal View History

2026-01-09 07:14:39 +00:00
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 };