feat(devicemanager): Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
This commit is contained in:
371
ts/features/feature.scan.ts
Normal file
371
ts/features/feature.scan.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* 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'];
|
||||
}
|
||||
Reference in New Issue
Block a user