initial
This commit is contained in:
423
ts/scanner/scanner.classes.esclprotocol.ts
Normal file
423
ts/scanner/scanner.classes.esclprotocol.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import * as plugins from '../plugins.js'; // Used for smartdelay
|
||||
import type {
|
||||
IEsclCapabilities,
|
||||
IEsclScanStatus,
|
||||
IEsclJobInfo,
|
||||
IScanOptions,
|
||||
IScanResult,
|
||||
TScanFormat,
|
||||
TColorMode,
|
||||
} from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* eSCL XML namespaces
|
||||
*/
|
||||
const NAMESPACES = {
|
||||
scan: 'http://schemas.hp.com/imaging/escl/2011/05/03',
|
||||
pwg: 'http://www.pwg.org/schemas/2010/12/sm',
|
||||
};
|
||||
|
||||
/**
|
||||
* Color mode mappings
|
||||
*/
|
||||
const COLOR_MODE_MAP: Record<TColorMode, string> = {
|
||||
color: 'RGB24',
|
||||
grayscale: 'Grayscale8',
|
||||
blackwhite: 'BlackAndWhite1',
|
||||
};
|
||||
|
||||
/**
|
||||
* Format MIME type mappings
|
||||
*/
|
||||
const FORMAT_MIME_MAP: Record<TScanFormat, string> = {
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
pdf: 'application/pdf',
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to make HTTP requests using native fetch (available in Node.js 18+)
|
||||
*/
|
||||
async function httpRequest(
|
||||
url: string,
|
||||
method: 'GET' | 'POST' | 'DELETE',
|
||||
body?: string
|
||||
): Promise<{ status: number; headers: Record<string, string>; body: string }> {
|
||||
const options: RequestInit = { method };
|
||||
|
||||
if (body) {
|
||||
options.headers = { 'Content-Type': 'text/xml; charset=utf-8' };
|
||||
options.body = body;
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
const responseBody = await response.text();
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
response.headers.forEach((value, key) => {
|
||||
headers[key.toLowerCase()] = value;
|
||||
});
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
headers,
|
||||
body: responseBody,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* eSCL/AirScan protocol client for network scanners
|
||||
*/
|
||||
export class EsclProtocol {
|
||||
private baseUrl: string;
|
||||
private capabilities: IEsclCapabilities | null = null;
|
||||
|
||||
constructor(address: string, port: number, secure: boolean = false) {
|
||||
const protocol = secure ? 'https' : 'http';
|
||||
this.baseUrl = `${protocol}://${address}:${port}/eSCL`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scanner capabilities
|
||||
*/
|
||||
public async getCapabilities(): Promise<IEsclCapabilities> {
|
||||
const response = await httpRequest(
|
||||
`${this.baseUrl}/ScannerCapabilities`,
|
||||
'GET'
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to get capabilities: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
this.capabilities = this.parseCapabilities(response.body);
|
||||
return this.capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scanner status
|
||||
*/
|
||||
public async getStatus(): Promise<IEsclScanStatus> {
|
||||
const response = await httpRequest(
|
||||
`${this.baseUrl}/ScannerStatus`,
|
||||
'GET'
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to get status: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return this.parseStatus(response.body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a scan job
|
||||
* Returns the job URI for tracking
|
||||
*/
|
||||
public async submitScanJob(options: IScanOptions): Promise<string> {
|
||||
const scanSettings = this.buildScanSettings(options);
|
||||
|
||||
// Use fetch for POST with body since SmartRequest API may not support raw body
|
||||
const response = await fetch(`${this.baseUrl}/ScanJobs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml; charset=utf-8',
|
||||
},
|
||||
body: scanSettings,
|
||||
});
|
||||
|
||||
if (response.status !== 201) {
|
||||
throw new Error(`Failed to submit scan job: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Get job URI from Location header
|
||||
const location = response.headers.get('location');
|
||||
if (!location) {
|
||||
throw new Error('No job location returned from scanner');
|
||||
}
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for scan job to complete and download the result
|
||||
*/
|
||||
public async waitForScanComplete(
|
||||
jobUri: string,
|
||||
options: IScanOptions,
|
||||
pollInterval: number = 500
|
||||
): Promise<IScanResult> {
|
||||
// Poll until job is complete
|
||||
let attempts = 0;
|
||||
const maxAttempts = 120; // 60 seconds max
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
const status = await this.getStatus();
|
||||
const job = status.jobs?.find((j) => jobUri.includes(j.jobUuid) || j.jobUri === jobUri);
|
||||
|
||||
if (job) {
|
||||
if (job.jobState === 'Completed') {
|
||||
break;
|
||||
} else if (job.jobState === 'Canceled' || job.jobState === 'Aborted') {
|
||||
throw new Error(`Scan job ${job.jobState}: ${job.jobStateReason || 'Unknown reason'}`);
|
||||
}
|
||||
}
|
||||
|
||||
await plugins.smartdelay.delayFor(pollInterval);
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
throw new Error('Scan job timed out');
|
||||
}
|
||||
|
||||
// Download the scanned document
|
||||
return this.downloadScan(jobUri, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download scanned document
|
||||
*/
|
||||
public async downloadScan(jobUri: string, options: IScanOptions): Promise<IScanResult> {
|
||||
const downloadUrl = `${jobUri}/NextDocument`;
|
||||
|
||||
const response = await fetch(downloadUrl, { method: 'GET' });
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to download scan: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const format = options.format ?? 'jpeg';
|
||||
const contentType = response.headers.get('content-type') ?? FORMAT_MIME_MAP[format];
|
||||
|
||||
// Get image dimensions from headers if available
|
||||
const width = parseInt(response.headers.get('x-image-width') || '0') || 0;
|
||||
const height = parseInt(response.headers.get('x-image-height') || '0') || 0;
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const data = Buffer.from(arrayBuffer);
|
||||
|
||||
return {
|
||||
data: data,
|
||||
format: format,
|
||||
width: width,
|
||||
height: height,
|
||||
resolution: options.resolution ?? 300,
|
||||
colorMode: options.colorMode ?? 'color',
|
||||
mimeType: contentType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a scan job
|
||||
*/
|
||||
public async cancelJob(jobUri: string): Promise<void> {
|
||||
const response = await fetch(jobUri, { method: 'DELETE' });
|
||||
|
||||
// 204 No Content or 200 OK are both acceptable
|
||||
if (response.status !== 200 && response.status !== 204) {
|
||||
throw new Error(`Failed to cancel job: HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build XML scan settings
|
||||
*/
|
||||
private buildScanSettings(options: IScanOptions): string {
|
||||
const resolution = options.resolution ?? 300;
|
||||
const colorMode = COLOR_MODE_MAP[options.colorMode ?? 'color'];
|
||||
const format = FORMAT_MIME_MAP[options.format ?? 'jpeg'];
|
||||
const source = this.mapSource(options.source ?? 'flatbed');
|
||||
const intent = options.intent ?? 'TextAndPhoto';
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<scan:ScanSettings xmlns:scan="${NAMESPACES.scan}" xmlns:pwg="${NAMESPACES.pwg}">
|
||||
<pwg:Version>2.0</pwg:Version>
|
||||
<scan:Intent>${intent}</scan:Intent>
|
||||
<pwg:ScanRegions>
|
||||
<pwg:ScanRegion>`;
|
||||
|
||||
if (options.area) {
|
||||
// Convert mm to 300ths of an inch (eSCL uses 300dpi as base unit)
|
||||
const toUnits = (mm: number) => Math.round((mm / 25.4) * 300);
|
||||
xml += `
|
||||
<pwg:XOffset>${toUnits(options.area.x)}</pwg:XOffset>
|
||||
<pwg:YOffset>${toUnits(options.area.y)}</pwg:YOffset>
|
||||
<pwg:Width>${toUnits(options.area.width)}</pwg:Width>
|
||||
<pwg:Height>${toUnits(options.area.height)}</pwg:Height>`;
|
||||
} else {
|
||||
// Full page (A4 default: 210x297mm)
|
||||
xml += `
|
||||
<pwg:XOffset>0</pwg:XOffset>
|
||||
<pwg:YOffset>0</pwg:YOffset>
|
||||
<pwg:Width>2480</pwg:Width>
|
||||
<pwg:Height>3508</pwg:Height>`;
|
||||
}
|
||||
|
||||
xml += `
|
||||
<pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits>
|
||||
</pwg:ScanRegion>
|
||||
</pwg:ScanRegions>
|
||||
<scan:DocumentFormatExt>${format}</scan:DocumentFormatExt>
|
||||
<scan:XResolution>${resolution}</scan:XResolution>
|
||||
<scan:YResolution>${resolution}</scan:YResolution>
|
||||
<scan:ColorMode>${colorMode}</scan:ColorMode>
|
||||
<scan:InputSource>${source}</scan:InputSource>`;
|
||||
|
||||
if (options.format === 'jpeg' && options.quality) {
|
||||
xml += `
|
||||
<scan:CompressionFactor>${100 - options.quality}</scan:CompressionFactor>`;
|
||||
}
|
||||
|
||||
xml += `
|
||||
</scan:ScanSettings>`;
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map source to eSCL format
|
||||
*/
|
||||
private mapSource(source: string): string {
|
||||
switch (source) {
|
||||
case 'flatbed':
|
||||
return 'Platen';
|
||||
case 'adf':
|
||||
return 'Feeder';
|
||||
case 'adf-duplex':
|
||||
return 'Duplex';
|
||||
default:
|
||||
return 'Platen';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse capabilities XML response
|
||||
*/
|
||||
private parseCapabilities(body: string): IEsclCapabilities {
|
||||
const xml = body;
|
||||
|
||||
// Simple XML parsing without full XML parser
|
||||
const getTagContent = (tag: string): string => {
|
||||
const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'i');
|
||||
const match = xml.match(regex);
|
||||
return match?.[1]?.trim() ?? '';
|
||||
};
|
||||
|
||||
const getAllTagContents = (tag: string): string[] => {
|
||||
const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'gi');
|
||||
const matches: string[] = [];
|
||||
let match;
|
||||
while ((match = regex.exec(xml)) !== null) {
|
||||
if (match[1]?.trim()) {
|
||||
matches.push(match[1].trim());
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
|
||||
const parseResolutions = (): number[] => {
|
||||
const resolutions = getAllTagContents('XResolution');
|
||||
return [...new Set(resolutions.map((r) => parseInt(r)).filter((r) => !isNaN(r)))];
|
||||
};
|
||||
|
||||
return {
|
||||
version: getTagContent('Version') || '2.0',
|
||||
makeAndModel: getTagContent('MakeAndModel') || getTagContent('Make') || 'Unknown',
|
||||
serialNumber: getTagContent('SerialNumber') || undefined,
|
||||
uuid: getTagContent('UUID') || undefined,
|
||||
adminUri: getTagContent('AdminURI') || undefined,
|
||||
iconUri: getTagContent('IconURI') || undefined,
|
||||
platen: xml.includes('Platen')
|
||||
? {
|
||||
minWidth: parseInt(getTagContent('MinWidth')) || 0,
|
||||
maxWidth: parseInt(getTagContent('MaxWidth')) || 2550,
|
||||
minHeight: parseInt(getTagContent('MinHeight')) || 0,
|
||||
maxHeight: parseInt(getTagContent('MaxHeight')) || 3508,
|
||||
maxScanRegions: parseInt(getTagContent('MaxScanRegions')) || 1,
|
||||
supportedResolutions: parseResolutions(),
|
||||
colorModes: getAllTagContents('ColorMode'),
|
||||
documentFormats: getAllTagContents('DocumentFormatExt'),
|
||||
}
|
||||
: undefined,
|
||||
adf: xml.includes('Adf') || xml.includes('ADF') || xml.includes('Feeder')
|
||||
? {
|
||||
minWidth: 0,
|
||||
maxWidth: 2550,
|
||||
minHeight: 0,
|
||||
maxHeight: 4200,
|
||||
maxScanRegions: 1,
|
||||
supportedResolutions: parseResolutions(),
|
||||
colorModes: getAllTagContents('ColorMode'),
|
||||
documentFormats: getAllTagContents('DocumentFormatExt'),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse status XML response
|
||||
*/
|
||||
private parseStatus(body: string): IEsclScanStatus {
|
||||
const xml = body;
|
||||
|
||||
const getTagContent = (tag: string): string => {
|
||||
const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'i');
|
||||
const match = xml.match(regex);
|
||||
return match?.[1]?.trim() ?? '';
|
||||
};
|
||||
|
||||
const state = getTagContent('State') || getTagContent('ScannerState') || 'Idle';
|
||||
const adfState = getTagContent('AdfState') || undefined;
|
||||
|
||||
// Parse jobs if present
|
||||
const jobs: IEsclJobInfo[] = [];
|
||||
const jobMatches = xml.match(/<[^:]*:?JobInfo[^>]*>[\s\S]*?<\/[^:]*:?JobInfo>/gi);
|
||||
if (jobMatches) {
|
||||
for (const jobXml of jobMatches) {
|
||||
const getJobTag = (tag: string): string => {
|
||||
const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'i');
|
||||
const match = jobXml.match(regex);
|
||||
return match?.[1]?.trim() ?? '';
|
||||
};
|
||||
|
||||
jobs.push({
|
||||
jobUri: getJobTag('JobUri') || getJobTag('JobURI') || '',
|
||||
jobUuid: getJobTag('JobUuid') || getJobTag('JobUUID') || '',
|
||||
age: parseInt(getJobTag('Age')) || 0,
|
||||
imagesCompleted: parseInt(getJobTag('ImagesCompleted')) || 0,
|
||||
imagesToTransfer: parseInt(getJobTag('ImagesToTransfer')) || 0,
|
||||
jobState: (getJobTag('JobState') as IEsclJobInfo['jobState']) || 'Pending',
|
||||
jobStateReason: getJobTag('JobStateReason') || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: state as IEsclScanStatus['state'],
|
||||
adfState: adfState as IEsclScanStatus['adfState'],
|
||||
jobs: jobs.length > 0 ? jobs : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a complete scan operation
|
||||
*/
|
||||
public async scan(options: IScanOptions): Promise<IScanResult> {
|
||||
// Submit the job
|
||||
const jobUri = await this.submitScanJob(options);
|
||||
|
||||
try {
|
||||
// Wait for completion and download
|
||||
return await this.waitForScanComplete(jobUri, options);
|
||||
} catch (error) {
|
||||
// Try to cancel the job on error
|
||||
try {
|
||||
await this.cancelJob(jobUri);
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
694
ts/scanner/scanner.classes.saneprotocol.ts
Normal file
694
ts/scanner/scanner.classes.saneprotocol.ts
Normal file
@@ -0,0 +1,694 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type {
|
||||
ISaneDevice,
|
||||
ISaneOption,
|
||||
ISaneParameters,
|
||||
IScanOptions,
|
||||
IScanResult,
|
||||
TColorMode,
|
||||
} from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* SANE network protocol RPC codes
|
||||
*/
|
||||
const enum SaneRpc {
|
||||
INIT = 0,
|
||||
GET_DEVICES = 1,
|
||||
OPEN = 2,
|
||||
CLOSE = 3,
|
||||
GET_OPTION_DESCRIPTORS = 4,
|
||||
CONTROL_OPTION = 5,
|
||||
GET_PARAMETERS = 6,
|
||||
START = 7,
|
||||
CANCEL = 8,
|
||||
AUTHORIZE = 9,
|
||||
EXIT = 10,
|
||||
}
|
||||
|
||||
/**
|
||||
* SANE status codes
|
||||
*/
|
||||
const enum SaneStatus {
|
||||
GOOD = 0,
|
||||
UNSUPPORTED = 1,
|
||||
CANCELLED = 2,
|
||||
DEVICE_BUSY = 3,
|
||||
INVAL = 4,
|
||||
EOF = 5,
|
||||
JAMMED = 6,
|
||||
NO_DOCS = 7,
|
||||
COVER_OPEN = 8,
|
||||
IO_ERROR = 9,
|
||||
NO_MEM = 10,
|
||||
ACCESS_DENIED = 11,
|
||||
}
|
||||
|
||||
/**
|
||||
* SANE option types
|
||||
*/
|
||||
const enum SaneValueType {
|
||||
BOOL = 0,
|
||||
INT = 1,
|
||||
FIXED = 2,
|
||||
STRING = 3,
|
||||
BUTTON = 4,
|
||||
GROUP = 5,
|
||||
}
|
||||
|
||||
/**
|
||||
* SANE option units
|
||||
*/
|
||||
const enum SaneUnit {
|
||||
NONE = 0,
|
||||
PIXEL = 1,
|
||||
BIT = 2,
|
||||
MM = 3,
|
||||
DPI = 4,
|
||||
PERCENT = 5,
|
||||
MICROSECOND = 6,
|
||||
}
|
||||
|
||||
/**
|
||||
* SANE constraint types
|
||||
*/
|
||||
const enum SaneConstraintType {
|
||||
NONE = 0,
|
||||
RANGE = 1,
|
||||
WORD_LIST = 2,
|
||||
STRING_LIST = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* SANE control option actions
|
||||
*/
|
||||
const enum SaneAction {
|
||||
GET_VALUE = 0,
|
||||
SET_VALUE = 1,
|
||||
SET_AUTO = 2,
|
||||
}
|
||||
|
||||
const SANE_PORT = 6566;
|
||||
const SANE_NET_PROTOCOL_VERSION = 3;
|
||||
|
||||
/**
|
||||
* Status code to error message mapping
|
||||
*/
|
||||
const STATUS_MESSAGES: Record<number, string> = {
|
||||
[SaneStatus.GOOD]: 'Success',
|
||||
[SaneStatus.UNSUPPORTED]: 'Operation not supported',
|
||||
[SaneStatus.CANCELLED]: 'Operation cancelled',
|
||||
[SaneStatus.DEVICE_BUSY]: 'Device busy',
|
||||
[SaneStatus.INVAL]: 'Invalid argument',
|
||||
[SaneStatus.EOF]: 'End of file',
|
||||
[SaneStatus.JAMMED]: 'Document feeder jammed',
|
||||
[SaneStatus.NO_DOCS]: 'No documents in feeder',
|
||||
[SaneStatus.COVER_OPEN]: 'Scanner cover open',
|
||||
[SaneStatus.IO_ERROR]: 'I/O error',
|
||||
[SaneStatus.NO_MEM]: 'Out of memory',
|
||||
[SaneStatus.ACCESS_DENIED]: 'Access denied',
|
||||
};
|
||||
|
||||
/**
|
||||
* SANE network protocol client
|
||||
*/
|
||||
export class SaneProtocol {
|
||||
private socket: plugins.net.Socket | null = null;
|
||||
private address: string;
|
||||
private port: number;
|
||||
private handle: number = -1;
|
||||
private options: ISaneOption[] = [];
|
||||
private readBuffer: Buffer = Buffer.alloc(0);
|
||||
|
||||
constructor(address: string, port: number = SANE_PORT) {
|
||||
this.address = address;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to SANE daemon
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket = plugins.net.createConnection(
|
||||
{ host: this.address, port: this.port },
|
||||
() => {
|
||||
this.init()
|
||||
.then(() => resolve())
|
||||
.catch(reject);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on('error', reject);
|
||||
this.socket.on('data', (data: Buffer) => {
|
||||
this.readBuffer = Buffer.concat([this.readBuffer, data]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from SANE daemon
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
if (this.handle >= 0) {
|
||||
await this.close();
|
||||
}
|
||||
|
||||
await this.exit();
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize connection (SANE_NET_INIT)
|
||||
*/
|
||||
private async init(): Promise<void> {
|
||||
const request = this.buildRequest(SaneRpc.INIT);
|
||||
this.writeWord(request, SANE_NET_PROTOCOL_VERSION);
|
||||
this.writeString(request, ''); // Username (empty for now)
|
||||
|
||||
await this.sendRequest(request);
|
||||
const response = await this.readResponse();
|
||||
|
||||
const status = this.readWord(response);
|
||||
const version = this.readWord(response);
|
||||
|
||||
if (status !== SaneStatus.GOOD) {
|
||||
throw new Error(`SANE init failed: ${STATUS_MESSAGES[status] || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available devices (SANE_NET_GET_DEVICES)
|
||||
*/
|
||||
public async getDevices(): Promise<ISaneDevice[]> {
|
||||
const request = this.buildRequest(SaneRpc.GET_DEVICES);
|
||||
await this.sendRequest(request);
|
||||
const response = await this.readResponse();
|
||||
|
||||
const status = this.readWord(response);
|
||||
if (status !== SaneStatus.GOOD) {
|
||||
throw new Error(`Failed to get devices: ${STATUS_MESSAGES[status]}`);
|
||||
}
|
||||
|
||||
const devices: ISaneDevice[] = [];
|
||||
const count = this.readWord(response);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const hasDevice = this.readWord(response);
|
||||
if (hasDevice) {
|
||||
devices.push({
|
||||
name: this.readString(response),
|
||||
vendor: this.readString(response),
|
||||
model: this.readString(response),
|
||||
type: this.readString(response),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return devices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a device (SANE_NET_OPEN)
|
||||
*/
|
||||
public async open(deviceName: string): Promise<void> {
|
||||
const request = this.buildRequest(SaneRpc.OPEN);
|
||||
this.writeString(request, deviceName);
|
||||
|
||||
await this.sendRequest(request);
|
||||
const response = await this.readResponse();
|
||||
|
||||
const status = this.readWord(response);
|
||||
if (status !== SaneStatus.GOOD) {
|
||||
// Check for authorization required
|
||||
const resource = this.readString(response);
|
||||
if (resource) {
|
||||
throw new Error(`Authorization required for: ${resource}`);
|
||||
}
|
||||
throw new Error(`Failed to open device: ${STATUS_MESSAGES[status]}`);
|
||||
}
|
||||
|
||||
this.handle = this.readWord(response);
|
||||
this.readString(response); // resource (should be empty on success)
|
||||
|
||||
// Get option descriptors
|
||||
await this.getOptionDescriptors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current device (SANE_NET_CLOSE)
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
if (this.handle < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = this.buildRequest(SaneRpc.CLOSE);
|
||||
this.writeWord(request, this.handle);
|
||||
|
||||
await this.sendRequest(request);
|
||||
await this.readResponse(); // Just read to clear buffer
|
||||
|
||||
this.handle = -1;
|
||||
this.options = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit connection (SANE_NET_EXIT)
|
||||
*/
|
||||
private async exit(): Promise<void> {
|
||||
const request = this.buildRequest(SaneRpc.EXIT);
|
||||
await this.sendRequest(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get option descriptors (SANE_NET_GET_OPTION_DESCRIPTORS)
|
||||
*/
|
||||
private async getOptionDescriptors(): Promise<void> {
|
||||
const request = this.buildRequest(SaneRpc.GET_OPTION_DESCRIPTORS);
|
||||
this.writeWord(request, this.handle);
|
||||
|
||||
await this.sendRequest(request);
|
||||
const response = await this.readResponse();
|
||||
|
||||
const count = this.readWord(response);
|
||||
this.options = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const hasOption = this.readWord(response);
|
||||
if (!hasOption) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const option: ISaneOption = {
|
||||
name: this.readString(response),
|
||||
title: this.readString(response),
|
||||
description: this.readString(response),
|
||||
type: this.mapValueType(this.readWord(response)),
|
||||
unit: this.mapUnit(this.readWord(response)),
|
||||
size: this.readWord(response),
|
||||
capabilities: this.readWord(response),
|
||||
constraintType: this.mapConstraintType(this.readWord(response)),
|
||||
};
|
||||
|
||||
// Read constraint based on type
|
||||
if (option.constraintType === 'range') {
|
||||
option.constraint = {
|
||||
range: {
|
||||
min: this.readWord(response),
|
||||
max: this.readWord(response),
|
||||
quant: this.readWord(response),
|
||||
},
|
||||
};
|
||||
} else if (option.constraintType === 'word_list') {
|
||||
const wordCount = this.readWord(response);
|
||||
const words: number[] = [];
|
||||
for (let j = 0; j < wordCount; j++) {
|
||||
words.push(this.readWord(response));
|
||||
}
|
||||
option.constraint = { wordList: words };
|
||||
} else if (option.constraintType === 'string_list') {
|
||||
const strings: string[] = [];
|
||||
let str: string;
|
||||
while ((str = this.readString(response)) !== '') {
|
||||
strings.push(str);
|
||||
}
|
||||
option.constraint = { stringList: strings };
|
||||
}
|
||||
|
||||
this.options.push(option);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an option value
|
||||
*/
|
||||
public async setOption(name: string, value: unknown): Promise<void> {
|
||||
const optionIndex = this.options.findIndex((o) => o.name === name);
|
||||
if (optionIndex < 0) {
|
||||
throw new Error(`Unknown option: ${name}`);
|
||||
}
|
||||
|
||||
const option = this.options[optionIndex];
|
||||
const request = this.buildRequest(SaneRpc.CONTROL_OPTION);
|
||||
|
||||
this.writeWord(request, this.handle);
|
||||
this.writeWord(request, optionIndex);
|
||||
this.writeWord(request, SaneAction.SET_VALUE);
|
||||
this.writeWord(request, option.type === 'string' ? (value as string).length + 1 : option.size);
|
||||
|
||||
// Write value based on type
|
||||
if (option.type === 'bool' || option.type === 'int') {
|
||||
this.writeWord(request, value as number);
|
||||
} else if (option.type === 'fixed') {
|
||||
this.writeWord(request, Math.round((value as number) * 65536));
|
||||
} else if (option.type === 'string') {
|
||||
this.writeString(request, value as string);
|
||||
}
|
||||
|
||||
await this.sendRequest(request);
|
||||
const response = await this.readResponse();
|
||||
|
||||
const status = this.readWord(response);
|
||||
if (status !== SaneStatus.GOOD) {
|
||||
throw new Error(`Failed to set option ${name}: ${STATUS_MESSAGES[status]}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scan parameters (SANE_NET_GET_PARAMETERS)
|
||||
*/
|
||||
public async getParameters(): Promise<ISaneParameters> {
|
||||
const request = this.buildRequest(SaneRpc.GET_PARAMETERS);
|
||||
this.writeWord(request, this.handle);
|
||||
|
||||
await this.sendRequest(request);
|
||||
const response = await this.readResponse();
|
||||
|
||||
const status = this.readWord(response);
|
||||
if (status !== SaneStatus.GOOD) {
|
||||
throw new Error(`Failed to get parameters: ${STATUS_MESSAGES[status]}`);
|
||||
}
|
||||
|
||||
const formatCode = this.readWord(response);
|
||||
const format = this.mapFormat(formatCode);
|
||||
|
||||
return {
|
||||
format,
|
||||
lastFrame: this.readWord(response) === 1,
|
||||
bytesPerLine: this.readWord(response),
|
||||
pixelsPerLine: this.readWord(response),
|
||||
lines: this.readWord(response),
|
||||
depth: this.readWord(response),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start scanning (SANE_NET_START)
|
||||
*/
|
||||
public async start(): Promise<{ port: number; byteOrder: 'little' | 'big' }> {
|
||||
const request = this.buildRequest(SaneRpc.START);
|
||||
this.writeWord(request, this.handle);
|
||||
|
||||
await this.sendRequest(request);
|
||||
const response = await this.readResponse();
|
||||
|
||||
const status = this.readWord(response);
|
||||
if (status !== SaneStatus.GOOD) {
|
||||
throw new Error(`Failed to start scan: ${STATUS_MESSAGES[status]}`);
|
||||
}
|
||||
|
||||
const port = this.readWord(response);
|
||||
const byteOrder = this.readWord(response) === 0x1234 ? 'little' : 'big';
|
||||
this.readString(response); // resource
|
||||
|
||||
return { port, byteOrder };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel scanning (SANE_NET_CANCEL)
|
||||
*/
|
||||
public async cancel(): Promise<void> {
|
||||
const request = this.buildRequest(SaneRpc.CANCEL);
|
||||
this.writeWord(request, this.handle);
|
||||
|
||||
await this.sendRequest(request);
|
||||
await this.readResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a complete scan
|
||||
*/
|
||||
public async scan(options: IScanOptions): Promise<IScanResult> {
|
||||
// Configure scan options
|
||||
await this.configureOptions(options);
|
||||
|
||||
// Get parameters
|
||||
const params = await this.getParameters();
|
||||
|
||||
// Start scan and get data port
|
||||
const { port, byteOrder } = await this.start();
|
||||
|
||||
// Connect to data port and read image data
|
||||
const imageData = await this.readImageData(port, params, byteOrder);
|
||||
|
||||
return {
|
||||
data: imageData,
|
||||
format: options.format ?? 'png',
|
||||
width: params.pixelsPerLine,
|
||||
height: params.lines,
|
||||
resolution: options.resolution ?? 300,
|
||||
colorMode: options.colorMode ?? 'color',
|
||||
mimeType: `image/${options.format ?? 'png'}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure scan options based on IScanOptions
|
||||
*/
|
||||
private async configureOptions(options: IScanOptions): Promise<void> {
|
||||
// Set resolution
|
||||
if (options.resolution) {
|
||||
const resOption = this.options.find((o) => o.name === 'resolution');
|
||||
if (resOption) {
|
||||
await this.setOption('resolution', options.resolution);
|
||||
}
|
||||
}
|
||||
|
||||
// Set color mode
|
||||
if (options.colorMode) {
|
||||
const modeOption = this.options.find((o) => o.name === 'mode');
|
||||
if (modeOption) {
|
||||
const modeValue = this.mapColorMode(options.colorMode);
|
||||
await this.setOption('mode', modeValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Set scan area
|
||||
if (options.area) {
|
||||
const tlxOption = this.options.find((o) => o.name === 'tl-x');
|
||||
const tlyOption = this.options.find((o) => o.name === 'tl-y');
|
||||
const brxOption = this.options.find((o) => o.name === 'br-x');
|
||||
const bryOption = this.options.find((o) => o.name === 'br-y');
|
||||
|
||||
if (tlxOption) await this.setOption('tl-x', options.area.x);
|
||||
if (tlyOption) await this.setOption('tl-y', options.area.y);
|
||||
if (brxOption) await this.setOption('br-x', options.area.x + options.area.width);
|
||||
if (bryOption) await this.setOption('br-y', options.area.y + options.area.height);
|
||||
}
|
||||
|
||||
// Set source
|
||||
if (options.source) {
|
||||
const sourceOption = this.options.find((o) => o.name === 'source');
|
||||
if (sourceOption) {
|
||||
await this.setOption('source', options.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read image data from data port
|
||||
*/
|
||||
private async readImageData(
|
||||
port: number,
|
||||
params: ISaneParameters,
|
||||
_byteOrder: 'little' | 'big'
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const dataSocket = plugins.net.createConnection(
|
||||
{ host: this.address, port },
|
||||
() => {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
dataSocket.on('data', (data: Buffer) => {
|
||||
// Data format: 4 bytes length + data
|
||||
// Read records until length is 0xFFFFFFFF (end marker)
|
||||
let offset = 0;
|
||||
while (offset < data.length) {
|
||||
if (offset + 4 > data.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const length = data.readUInt32BE(offset);
|
||||
offset += 4;
|
||||
|
||||
if (length === 0xffffffff) {
|
||||
// End of data
|
||||
dataSocket.destroy();
|
||||
resolve(Buffer.concat(chunks));
|
||||
return;
|
||||
}
|
||||
|
||||
if (offset + length > data.length) {
|
||||
// Incomplete record, wait for more data
|
||||
break;
|
||||
}
|
||||
|
||||
chunks.push(data.subarray(offset, offset + length));
|
||||
offset += length;
|
||||
}
|
||||
});
|
||||
|
||||
dataSocket.on('end', () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
|
||||
dataSocket.on('error', reject);
|
||||
}
|
||||
);
|
||||
|
||||
dataSocket.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map color mode to SANE mode string
|
||||
*/
|
||||
private mapColorMode(mode: TColorMode): string {
|
||||
switch (mode) {
|
||||
case 'color':
|
||||
return 'Color';
|
||||
case 'grayscale':
|
||||
return 'Gray';
|
||||
case 'blackwhite':
|
||||
return 'Lineart';
|
||||
default:
|
||||
return 'Color';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map SANE format code to string
|
||||
*/
|
||||
private mapFormat(code: number): ISaneParameters['format'] {
|
||||
const formats: Record<number, ISaneParameters['format']> = {
|
||||
0: 'gray',
|
||||
1: 'rgb',
|
||||
2: 'red',
|
||||
3: 'green',
|
||||
4: 'blue',
|
||||
};
|
||||
return formats[code] ?? 'gray';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map value type code to string
|
||||
*/
|
||||
private mapValueType(code: number): ISaneOption['type'] {
|
||||
const types: Record<number, ISaneOption['type']> = {
|
||||
[SaneValueType.BOOL]: 'bool',
|
||||
[SaneValueType.INT]: 'int',
|
||||
[SaneValueType.FIXED]: 'fixed',
|
||||
[SaneValueType.STRING]: 'string',
|
||||
[SaneValueType.BUTTON]: 'button',
|
||||
[SaneValueType.GROUP]: 'group',
|
||||
};
|
||||
return types[code] ?? 'int';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map unit code to string
|
||||
*/
|
||||
private mapUnit(code: number): ISaneOption['unit'] {
|
||||
const units: Record<number, ISaneOption['unit']> = {
|
||||
[SaneUnit.NONE]: 'none',
|
||||
[SaneUnit.PIXEL]: 'pixel',
|
||||
[SaneUnit.BIT]: 'bit',
|
||||
[SaneUnit.MM]: 'mm',
|
||||
[SaneUnit.DPI]: 'dpi',
|
||||
[SaneUnit.PERCENT]: 'percent',
|
||||
[SaneUnit.MICROSECOND]: 'microsecond',
|
||||
};
|
||||
return units[code] ?? 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map constraint type code to string
|
||||
*/
|
||||
private mapConstraintType(code: number): ISaneOption['constraintType'] {
|
||||
const types: Record<number, ISaneOption['constraintType']> = {
|
||||
[SaneConstraintType.NONE]: 'none',
|
||||
[SaneConstraintType.RANGE]: 'range',
|
||||
[SaneConstraintType.WORD_LIST]: 'word_list',
|
||||
[SaneConstraintType.STRING_LIST]: 'string_list',
|
||||
};
|
||||
return types[code] ?? 'none';
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Low-level protocol helpers
|
||||
// =========================================================================
|
||||
|
||||
private buildRequest(rpc: SaneRpc): Buffer[] {
|
||||
const chunks: Buffer[] = [];
|
||||
const header = Buffer.alloc(4);
|
||||
header.writeUInt32BE(rpc, 0);
|
||||
chunks.push(header);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private writeWord(chunks: Buffer[], value: number): void {
|
||||
const buf = Buffer.alloc(4);
|
||||
buf.writeUInt32BE(value >>> 0, 0);
|
||||
chunks.push(buf);
|
||||
}
|
||||
|
||||
private writeString(chunks: Buffer[], str: string): void {
|
||||
const strBuf = Buffer.from(str + '\0', 'utf-8');
|
||||
this.writeWord(chunks, strBuf.length);
|
||||
chunks.push(strBuf);
|
||||
}
|
||||
|
||||
private async sendRequest(chunks: Buffer[]): Promise<void> {
|
||||
if (!this.socket) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const data = Buffer.concat(chunks);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket!.write(data, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async readResponse(): Promise<{ buffer: Buffer; offset: number }> {
|
||||
// Wait for data
|
||||
await this.waitForData(4);
|
||||
|
||||
return { buffer: this.readBuffer, offset: 0 };
|
||||
}
|
||||
|
||||
private async waitForData(minBytes: number): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const timeout = 30000;
|
||||
|
||||
while (this.readBuffer.length < minBytes) {
|
||||
if (Date.now() - startTime > timeout) {
|
||||
throw new Error('Timeout waiting for SANE response');
|
||||
}
|
||||
await plugins.smartdelay.delayFor(10);
|
||||
}
|
||||
}
|
||||
|
||||
private readWord(response: { buffer: Buffer; offset: number }): number {
|
||||
const value = response.buffer.readUInt32BE(response.offset);
|
||||
response.offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
private readString(response: { buffer: Buffer; offset: number }): string {
|
||||
const length = this.readWord(response);
|
||||
if (length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const str = response.buffer.toString('utf-8', response.offset, response.offset + length - 1);
|
||||
response.offset += length;
|
||||
return str;
|
||||
}
|
||||
}
|
||||
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