Files
devicemanager/ts/protocols/protocol.escl.ts

440 lines
13 KiB
TypeScript

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',
tiff: 'image/tiff',
};
/**
* 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
*
* Note: Many scanners (including Brother) don't start scanning until
* NextDocument is requested. The request triggers the scan and blocks
* until complete. We try direct download first, then fall back to polling.
*/
public async waitForScanComplete(
jobUri: string,
options: IScanOptions,
pollInterval: number = 500
): Promise<IScanResult> {
// Try direct download first - this triggers the scan on many scanners
// (including Brother) and blocks until the scan completes
try {
const result = await this.downloadScan(jobUri, options);
if (result.data.length > 0) {
return result;
}
} catch (err) {
// Direct download failed, fall back to polling
}
// Fall back to polling for scanners that need it
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;
}
}
}