Files
devicemanager/ts/features/feature.scan.ts

372 lines
12 KiB
TypeScript
Raw Normal View History

/**
* Scan Feature
* Provides document scanning capability using eSCL or SANE protocols
*/
import { Feature, type TDeviceReference } from './feature.abstract.js';
import { EsclProtocol } from '../scanner/scanner.classes.esclprotocol.js';
import { SaneProtocol } from '../scanner/scanner.classes.saneprotocol.js';
import type {
TScanProtocol,
TScanFormat,
TColorMode,
TScanSource,
IScanCapabilities,
IScanOptions,
IScanResult,
IScanFeatureInfo,
IFeatureOptions,
} from '../interfaces/feature.interfaces.js';
/**
* Options for creating a ScanFeature
*/
export interface IScanFeatureOptions extends IFeatureOptions {
protocol: TScanProtocol;
secure?: boolean;
deviceName?: string; // For SANE
supportedFormats?: TScanFormat[];
supportedResolutions?: number[];
supportedColorModes?: TColorMode[];
supportedSources?: TScanSource[];
hasAdf?: boolean;
hasDuplex?: boolean;
}
/**
* Scan Feature - provides document scanning capability
*
* Wraps eSCL (AirScan) or SANE protocols to provide a unified scanning interface.
*
* @example
* ```typescript
* const scanFeature = device.getFeature<ScanFeature>('scan');
* if (scanFeature) {
* await scanFeature.connect();
* const result = await scanFeature.scan({ format: 'pdf', resolution: 300 });
* console.log(`Scanned ${result.width}x${result.height} pixels`);
* }
* ```
*/
export class ScanFeature extends Feature {
public readonly type = 'scan' as const;
public readonly protocol: TScanProtocol;
// Protocol clients
private esclClient: EsclProtocol | null = null;
private saneClient: SaneProtocol | null = null;
// Configuration
private readonly isSecure: boolean;
private readonly deviceName: string;
// Capabilities
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
public minWidth: number = 10;
public minHeight: number = 10;
constructor(
device: TDeviceReference,
port: number,
options: IScanFeatureOptions
) {
super(device, port, options);
this.protocol = options.protocol;
this.isSecure = options.secure ?? false;
this.deviceName = options.deviceName ?? '';
// Set capabilities from options if provided
if (options.supportedFormats) this.supportedFormats = options.supportedFormats;
if (options.supportedResolutions) this.supportedResolutions = options.supportedResolutions;
if (options.supportedColorModes) this.supportedColorModes = options.supportedColorModes;
if (options.supportedSources) this.supportedSources = options.supportedSources;
if (options.hasAdf !== undefined) this.hasAdf = options.hasAdf;
if (options.hasDuplex !== undefined) this.hasDuplex = options.hasDuplex;
}
// ============================================================================
// Connection
// ============================================================================
protected async doConnect(): Promise<void> {
if (this.protocol === 'escl') {
this.esclClient = new EsclProtocol(this.address, this._port, this.isSecure);
// Fetch capabilities to verify connection
const caps = await this.esclClient.getCapabilities();
this.updateCapabilitiesFromEscl(caps);
} else if (this.protocol === 'sane') {
this.saneClient = new SaneProtocol(this.address, this._port);
await this.saneClient.connect();
// Open the device if we have a name
if (this.deviceName) {
await this.saneClient.open(this.deviceName);
}
}
}
protected async doDisconnect(): Promise<void> {
if (this.saneClient) {
try {
await this.saneClient.close();
await this.saneClient.disconnect();
} catch {
// Ignore disconnect errors
}
this.saneClient = null;
}
this.esclClient = null;
}
// ============================================================================
// Scanning Operations
// ============================================================================
/**
* Get scanner capabilities
*/
public async getCapabilities(): Promise<IScanCapabilities> {
return {
resolutions: this.supportedResolutions,
formats: this.supportedFormats,
colorModes: this.supportedColorModes,
sources: this.supportedSources,
maxWidth: this.maxWidth,
maxHeight: this.maxHeight,
minWidth: this.minWidth,
minHeight: this.minHeight,
};
}
/**
* Perform a scan
*/
public async scan(options?: IScanOptions): Promise<IScanResult> {
if (!this.isConnected) {
throw new Error('Scan feature not connected');
}
const opts = this.resolveOptions(options);
if (this.protocol === 'escl' && this.esclClient) {
return this.scanWithEscl(opts);
} else if (this.protocol === 'sane' && this.saneClient) {
return this.scanWithSane(opts);
}
throw new Error('No scanner protocol available');
}
/**
* Cancel an ongoing scan
*/
public async cancelScan(): Promise<void> {
if (this.protocol === 'sane' && this.saneClient) {
await this.saneClient.cancel();
}
// eSCL cancellation is handled by deleting the job
this.emit('scan:cancelled');
}
// ============================================================================
// Protocol-Specific Scanning
// ============================================================================
private async scanWithEscl(options: Required<IScanOptions>): Promise<IScanResult> {
if (!this.esclClient) {
throw new Error('eSCL client not initialized');
}
this.emit('scan:started', options);
// Use the protocol's scan method which handles job submission,
// waiting for completion, and downloading in one operation
const result = await this.esclClient.scan({
format: options.format,
resolution: options.resolution,
colorMode: options.colorMode,
source: options.source,
area: options.area,
intent: options.intent,
});
this.emit('scan:completed', result);
return result;
}
private async scanWithSane(options: Required<IScanOptions>): Promise<IScanResult> {
if (!this.saneClient) {
throw new Error('SANE client not initialized');
}
this.emit('scan:started', options);
// Use the protocol's scan method which handles option configuration,
// parameter retrieval, and image reading in one operation
const result = await this.saneClient.scan({
format: options.format,
resolution: options.resolution,
colorMode: options.colorMode,
source: options.source,
area: options.area,
});
this.emit('scan:completed', result);
return result;
}
// ============================================================================
// Helper Methods
// ============================================================================
private resolveOptions(options?: IScanOptions): Required<IScanOptions> {
return {
resolution: options?.resolution ?? 300,
format: options?.format ?? 'jpeg',
colorMode: options?.colorMode ?? 'color',
source: options?.source ?? 'flatbed',
area: options?.area ?? {
x: 0,
y: 0,
width: this.maxWidth,
height: this.maxHeight,
},
intent: options?.intent ?? 'document',
quality: options?.quality ?? 85,
};
}
private updateCapabilitiesFromEscl(caps: any): void {
if (caps.platen) {
this.supportedResolutions = caps.platen.supportedResolutions || this.supportedResolutions;
this.maxWidth = caps.platen.maxWidth || this.maxWidth;
this.maxHeight = caps.platen.maxHeight || this.maxHeight;
this.minWidth = caps.platen.minWidth || this.minWidth;
this.minHeight = caps.platen.minHeight || this.minHeight;
}
if (caps.adf) {
this.hasAdf = true;
if (!this.supportedSources.includes('adf')) {
this.supportedSources.push('adf');
}
}
if (caps.adfDuplex) {
this.hasDuplex = true;
if (!this.supportedSources.includes('adf-duplex')) {
this.supportedSources.push('adf-duplex');
}
}
}
private colorModeToSane(mode: TColorMode): string {
switch (mode) {
case 'color': return 'Color';
case 'grayscale': return 'Gray';
case 'blackwhite': return 'Lineart';
default: return 'Color';
}
}
private getMimeType(format: TScanFormat): string {
switch (format) {
case 'jpeg': return 'image/jpeg';
case 'png': return 'image/png';
case 'pdf': return 'application/pdf';
case 'tiff': return 'image/tiff';
default: return 'application/octet-stream';
}
}
// ============================================================================
// Serialization
// ============================================================================
public getFeatureInfo(): IScanFeatureInfo {
return {
...this.getBaseFeatureInfo(),
type: 'scan',
protocol: this.protocol,
supportedFormats: this.supportedFormats,
supportedResolutions: this.supportedResolutions,
supportedColorModes: this.supportedColorModes,
supportedSources: this.supportedSources,
hasAdf: this.hasAdf,
hasDuplex: this.hasDuplex,
};
}
// ============================================================================
// Static Factory
// ============================================================================
/**
* Create from discovery metadata
*/
public static fromDiscovery(
device: TDeviceReference,
port: number,
protocol: TScanProtocol,
metadata: Record<string, unknown>
): ScanFeature {
const txtRecords = metadata.txtRecords as Record<string, string> ?? {};
return new ScanFeature(device, port, {
protocol,
secure: metadata.secure as boolean ?? port === 443,
deviceName: metadata.deviceName as string,
supportedFormats: parseFormats(txtRecords),
supportedResolutions: parseResolutions(txtRecords),
supportedColorModes: parseColorModes(txtRecords),
supportedSources: parseSources(txtRecords),
hasAdf: Boolean(metadata.hasAdf),
hasDuplex: Boolean(metadata.hasDuplex),
});
}
}
// ============================================================================
// Parsing Helpers
// ============================================================================
function parseFormats(txt: Record<string, string>): TScanFormat[] {
const pdl = txt['pdl'] || '';
const formats: TScanFormat[] = [];
if (pdl.includes('image/jpeg')) formats.push('jpeg');
if (pdl.includes('image/png')) formats.push('png');
if (pdl.includes('application/pdf')) formats.push('pdf');
if (pdl.includes('image/tiff')) formats.push('tiff');
return formats.length > 0 ? formats : ['jpeg', 'png', 'pdf'];
}
function parseResolutions(txt: Record<string, string>): number[] {
const rs = txt['rs'] || '';
if (!rs) return [75, 150, 300, 600];
return rs.split(',')
.map((r) => parseInt(r.trim(), 10))
.filter((r) => !isNaN(r) && r > 0);
}
function parseColorModes(txt: Record<string, string>): TColorMode[] {
const cs = txt['cs'] || '';
const modes: TColorMode[] = [];
if (cs.includes('color') || cs.includes('RGB')) modes.push('color');
if (cs.includes('grayscale') || cs.includes('gray')) modes.push('grayscale');
if (cs.includes('binary') || cs.includes('lineart')) modes.push('blackwhite');
return modes.length > 0 ? modes : ['color', 'grayscale', 'blackwhite'];
}
function parseSources(txt: Record<string, string>): TScanSource[] {
const is = txt['is'] || '';
const sources: TScanSource[] = [];
if (is.includes('platen') || is.includes('flatbed') || !is) sources.push('flatbed');
if (is.includes('adf') && is.includes('duplex')) sources.push('adf-duplex');
else if (is.includes('adf')) sources.push('adf');
return sources.length > 0 ? sources : ['flatbed'];
}