@ecobridge.xyz/devicemanager 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitea/workflows/default_tags.yaml +0 -21
- package/.smartconfig.json +69 -0
- package/changelog.md +38 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/device/device.classes.device.d.ts +2 -2
- package/dist_ts/device/device.classes.device.js +3 -3
- package/dist_ts/devicemanager.classes.devicemanager.d.ts +17 -4
- package/dist_ts/devicemanager.classes.devicemanager.js +99 -11
- package/dist_ts/discovery/discovery.classes.mdns.js +46 -11
- package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +5 -1
- package/dist_ts/discovery/discovery.classes.networkscanner.js +44 -3
- package/dist_ts/discovery/discovery.classes.ssdp.d.ts +52 -21
- package/dist_ts/discovery/discovery.classes.ssdp.js +407 -117
- package/dist_ts/events/events.observable.d.ts +15 -0
- package/dist_ts/events/events.observable.js +22 -0
- package/dist_ts/events/index.d.ts +1 -0
- package/dist_ts/events/index.js +2 -0
- package/dist_ts/factories/index.js +7 -5
- package/dist_ts/features/feature.abstract.d.ts +2 -2
- package/dist_ts/features/feature.abstract.js +3 -3
- package/dist_ts/features/feature.print.d.ts +3 -0
- package/dist_ts/features/feature.print.js +17 -2
- package/dist_ts/features/feature.scan.d.ts +5 -2
- package/dist_ts/features/feature.scan.js +98 -27
- package/dist_ts/index.d.ts +4 -2
- package/dist_ts/index.js +4 -2
- package/dist_ts/interfaces/feature.interfaces.d.ts +22 -2
- package/dist_ts/interfaces/index.d.ts +22 -4
- package/dist_ts/interfaces/index.js +1 -1
- package/dist_ts/plugins.d.ts +6 -15
- package/dist_ts/plugins.js +7 -19
- package/dist_ts/protocols/index.d.ts +2 -1
- package/dist_ts/protocols/index.js +4 -2
- package/dist_ts/protocols/protocol.brother.d.ts +67 -0
- package/dist_ts/protocols/protocol.brother.js +560 -0
- package/dist_ts/protocols/protocol.escl.d.ts +13 -4
- package/dist_ts/protocols/protocol.escl.js +345 -178
- package/dist_ts/protocols/protocol.ipp.d.ts +2 -0
- package/dist_ts/protocols/protocol.ipp.js +22 -2
- package/dist_ts/providers/index.d.ts +1 -0
- package/dist_ts/providers/index.js +2 -0
- package/dist_ts/providers/provider.smb.d.ts +60 -0
- package/dist_ts/providers/provider.smb.js +224 -0
- package/license.md +21 -0
- package/package.json +15 -20
- package/pnpm-workspace.yaml +6 -0
- package/readme.md +157 -78
- package/test/test.brother.node.ts +306 -0
- package/test/test.escl.node.ts +233 -0
- package/test/test.ipp.node.ts +75 -0
- package/test/{test.ts → test.node.ts} +37 -45
- package/test/test.smb.node.ts +123 -0
- package/test/test.ssdp.node.ts +978 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/device/device.classes.device.ts +2 -2
- package/ts/devicemanager.classes.devicemanager.ts +125 -12
- package/ts/discovery/discovery.classes.mdns.ts +40 -11
- package/ts/discovery/discovery.classes.networkscanner.ts +51 -2
- package/ts/discovery/discovery.classes.ssdp.ts +493 -130
- package/ts/events/events.observable.ts +31 -0
- package/ts/events/index.ts +4 -0
- package/ts/factories/index.ts +7 -5
- package/ts/features/feature.abstract.ts +2 -2
- package/ts/features/feature.print.ts +16 -1
- package/ts/features/feature.scan.ts +93 -27
- package/ts/index.ts +21 -0
- package/ts/interfaces/feature.interfaces.ts +23 -2
- package/ts/interfaces/index.ts +23 -4
- package/ts/plugins.ts +6 -25
- package/ts/protocols/index.ts +11 -1
- package/ts/protocols/protocol.brother.ts +648 -0
- package/ts/protocols/protocol.escl.ts +348 -191
- package/ts/protocols/protocol.ipp.ts +24 -1
- package/ts/providers/index.ts +7 -0
- package/ts/providers/provider.smb.ts +326 -0
- package/dist_ts/protocols/protocol.ipp.old.d.ts +0 -45
- package/dist_ts/protocols/protocol.ipp.old.js +0 -284
- package/npmextra.json +0 -24
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js'; // Used for smartdelay
|
|
2
|
+
import { XMLBuilder, XMLParser } from 'fast-xml-parser';
|
|
2
3
|
import type {
|
|
3
4
|
IEsclCapabilities,
|
|
4
5
|
IEsclScanStatus,
|
|
@@ -36,6 +37,113 @@ const FORMAT_MIME_MAP: Record<TScanFormat, string> = {
|
|
|
36
37
|
tiff: 'image/tiff',
|
|
37
38
|
};
|
|
38
39
|
|
|
40
|
+
const STATUS_POLL_INTERVAL_MS = 500;
|
|
41
|
+
const MAX_STATUS_POLLS = 240;
|
|
42
|
+
const MAX_SCAN_DOCUMENTS = 500;
|
|
43
|
+
const CONTROL_REQUEST_TIMEOUT_MS = 30_000;
|
|
44
|
+
const DOCUMENT_REQUEST_TIMEOUT_MS = 10 * 60_000;
|
|
45
|
+
|
|
46
|
+
type TXmlRecord = Record<string, unknown>;
|
|
47
|
+
|
|
48
|
+
const xmlParser = new XMLParser({
|
|
49
|
+
ignoreAttributes: true,
|
|
50
|
+
removeNSPrefix: true,
|
|
51
|
+
trimValues: true,
|
|
52
|
+
parseTagValue: false,
|
|
53
|
+
processEntities: false,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const xmlBuilder = new XMLBuilder({
|
|
57
|
+
ignoreAttributes: false,
|
|
58
|
+
format: true,
|
|
59
|
+
suppressEmptyNode: false,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function asRecord(value: unknown): TXmlRecord {
|
|
63
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
64
|
+
? value as TXmlRecord
|
|
65
|
+
: {};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function asArray<T>(value: T | T[] | undefined): T[] {
|
|
69
|
+
if (value === undefined) return [];
|
|
70
|
+
return Array.isArray(value) ? value : [value];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function asText(value: unknown): string {
|
|
74
|
+
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function asInteger(value: unknown, fallback: number = 0): number {
|
|
78
|
+
const parsed = Number.parseInt(asText(value), 10);
|
|
79
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function unique<T>(values: T[]): T[] {
|
|
83
|
+
return [...new Set(values)];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function collectValues(value: unknown, key: string): unknown[] {
|
|
87
|
+
if (Array.isArray(value)) {
|
|
88
|
+
return value.flatMap((item) => collectValues(item, key));
|
|
89
|
+
}
|
|
90
|
+
if (value === null || typeof value !== 'object') return [];
|
|
91
|
+
const record = value as TXmlRecord;
|
|
92
|
+
return Object.entries(record).flatMap(([entryKey, entryValue]) =>
|
|
93
|
+
entryKey === key ? asArray(entryValue) : collectValues(entryValue, key)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function esclUnitsToMillimetres(value: unknown, fallback: number): number {
|
|
98
|
+
const units = asInteger(value);
|
|
99
|
+
return units > 0 ? (units / 300) * 25.4 : fallback;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function formatFromMimeType(mimeType: string, fallback: TScanFormat): TScanFormat {
|
|
103
|
+
const normalized = mimeType.split(';', 1)[0].trim().toLowerCase();
|
|
104
|
+
if (normalized === 'application/pdf') return 'pdf';
|
|
105
|
+
if (normalized === 'image/jpeg') return 'jpeg';
|
|
106
|
+
if (normalized === 'image/png') return 'png';
|
|
107
|
+
if (normalized === 'image/tiff') return 'tiff';
|
|
108
|
+
return fallback;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readJpegMetadata(data: Buffer): { width?: number; height?: number; resolution?: number } {
|
|
112
|
+
if (data.length < 4 || data[0] !== 0xff || data[1] !== 0xd8) return {};
|
|
113
|
+
const metadata: { width?: number; height?: number; resolution?: number } = {};
|
|
114
|
+
const startOfFrameMarkers = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
|
|
115
|
+
let offset = 2;
|
|
116
|
+
while (offset + 4 <= data.length) {
|
|
117
|
+
while (offset < data.length && data[offset] === 0xff) offset++;
|
|
118
|
+
const marker = data[offset++];
|
|
119
|
+
if (marker === 0xd9 || marker === 0xda) break;
|
|
120
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
|
121
|
+
if (offset + 2 > data.length) break;
|
|
122
|
+
const length = data.readUInt16BE(offset);
|
|
123
|
+
if (length < 2 || offset + length > data.length) break;
|
|
124
|
+
const content = offset + 2;
|
|
125
|
+
if (marker === 0xe0 && length >= 16 && data.toString('ascii', content, content + 5) === 'JFIF\0') {
|
|
126
|
+
const units = data[content + 7];
|
|
127
|
+
const density = data.readUInt16BE(content + 8);
|
|
128
|
+
if (units === 1 && density > 0) metadata.resolution = density;
|
|
129
|
+
if (units === 2 && density > 0) metadata.resolution = Math.round(density * 2.54);
|
|
130
|
+
}
|
|
131
|
+
if (startOfFrameMarkers.has(marker) && length >= 7) {
|
|
132
|
+
metadata.height = data.readUInt16BE(content + 1);
|
|
133
|
+
metadata.width = data.readUInt16BE(content + 3);
|
|
134
|
+
}
|
|
135
|
+
offset += length;
|
|
136
|
+
}
|
|
137
|
+
return metadata;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class EsclScanError extends Error {
|
|
141
|
+
constructor(message: string, public readonly partialResult?: IScanResult) {
|
|
142
|
+
super(message);
|
|
143
|
+
this.name = 'EsclScanError';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
39
147
|
/**
|
|
40
148
|
* Helper to make HTTP requests using native fetch (available in Node.js 18+)
|
|
41
149
|
*/
|
|
@@ -44,7 +152,10 @@ async function httpRequest(
|
|
|
44
152
|
method: 'GET' | 'POST' | 'DELETE',
|
|
45
153
|
body?: string
|
|
46
154
|
): Promise<{ status: number; headers: Record<string, string>; body: string }> {
|
|
47
|
-
const options: RequestInit = {
|
|
155
|
+
const options: RequestInit = {
|
|
156
|
+
method,
|
|
157
|
+
signal: AbortSignal.timeout(CONTROL_REQUEST_TIMEOUT_MS),
|
|
158
|
+
};
|
|
48
159
|
|
|
49
160
|
if (body) {
|
|
50
161
|
options.headers = { 'Content-Type': 'text/xml; charset=utf-8' };
|
|
@@ -72,6 +183,7 @@ async function httpRequest(
|
|
|
72
183
|
export class EsclProtocol {
|
|
73
184
|
private baseUrl: string;
|
|
74
185
|
private capabilities: IEsclCapabilities | null = null;
|
|
186
|
+
private activeJobUri: string | null = null;
|
|
75
187
|
|
|
76
188
|
constructor(address: string, port: number, secure: boolean = false) {
|
|
77
189
|
const protocol = secure ? 'https' : 'http';
|
|
@@ -118,13 +230,14 @@ export class EsclProtocol {
|
|
|
118
230
|
public async submitScanJob(options: IScanOptions): Promise<string> {
|
|
119
231
|
const scanSettings = this.buildScanSettings(options);
|
|
120
232
|
|
|
121
|
-
// Use fetch for
|
|
233
|
+
// Use fetch for the raw XML request body.
|
|
122
234
|
const response = await fetch(`${this.baseUrl}/ScanJobs`, {
|
|
123
235
|
method: 'POST',
|
|
124
236
|
headers: {
|
|
125
|
-
'Content-Type': 'text/xml
|
|
237
|
+
'Content-Type': 'text/xml',
|
|
126
238
|
},
|
|
127
239
|
body: scanSettings,
|
|
240
|
+
signal: AbortSignal.timeout(CONTROL_REQUEST_TIMEOUT_MS),
|
|
128
241
|
});
|
|
129
242
|
|
|
130
243
|
if (response.status !== 201) {
|
|
@@ -137,7 +250,7 @@ export class EsclProtocol {
|
|
|
137
250
|
throw new Error('No job location returned from scanner');
|
|
138
251
|
}
|
|
139
252
|
|
|
140
|
-
return location;
|
|
253
|
+
return this.resolveJobUri(location);
|
|
141
254
|
}
|
|
142
255
|
|
|
143
256
|
/**
|
|
@@ -150,77 +263,136 @@ export class EsclProtocol {
|
|
|
150
263
|
public async waitForScanComplete(
|
|
151
264
|
jobUri: string,
|
|
152
265
|
options: IScanOptions,
|
|
153
|
-
pollInterval: number =
|
|
266
|
+
pollInterval: number = STATUS_POLL_INTERVAL_MS
|
|
154
267
|
): Promise<IScanResult> {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
try {
|
|
158
|
-
const result = await this.downloadScan(jobUri, options);
|
|
159
|
-
if (result.data.length > 0) {
|
|
160
|
-
return result;
|
|
161
|
-
}
|
|
162
|
-
} catch (err) {
|
|
163
|
-
// Direct download failed, fall back to polling
|
|
164
|
-
}
|
|
268
|
+
const pages: IScanResult[] = [];
|
|
269
|
+
let emptyPolls = 0;
|
|
165
270
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
271
|
+
while (pages.length < MAX_SCAN_DOCUMENTS) {
|
|
272
|
+
let next: IScanResult | null;
|
|
273
|
+
const requestStartedAt = Date.now();
|
|
274
|
+
try {
|
|
275
|
+
next = await this.downloadNextDocument(jobUri, options);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (pages.length === 0) throw error;
|
|
278
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
279
|
+
throw new EsclScanError(message, this.combinePages(pages, false, message));
|
|
280
|
+
}
|
|
281
|
+
if (next) {
|
|
282
|
+
pages.push(next);
|
|
283
|
+
emptyPolls = 0;
|
|
284
|
+
const delay = this.nextDocumentDelay(options.source, Date.now() - requestStartedAt);
|
|
285
|
+
if (delay > 0) await plugins.smartdelay.delayFor(delay);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
169
288
|
|
|
170
|
-
while (attempts < maxAttempts) {
|
|
171
289
|
const status = await this.getStatus();
|
|
172
|
-
const job =
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
290
|
+
const job = this.findJob(status, jobUri);
|
|
291
|
+
if (job?.jobState === 'Canceled' || job?.jobState === 'Aborted') {
|
|
292
|
+
const reason = job.jobStateReason || 'Unknown reason';
|
|
293
|
+
const message = `Scan job ${job.jobState}: ${reason}`;
|
|
294
|
+
throw new EsclScanError(
|
|
295
|
+
message,
|
|
296
|
+
pages.length > 0 ? this.combinePages(pages, false, reason) : undefined
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (job?.jobState === 'Completed' || (!job && pages.length > 0)) {
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
if (status.state === 'Stopped') {
|
|
303
|
+
const message = 'Scanner stopped before the scan completed';
|
|
304
|
+
throw new EsclScanError(
|
|
305
|
+
message,
|
|
306
|
+
pages.length > 0 ? this.combinePages(pages, false, message) : undefined
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
if (++emptyPolls >= MAX_STATUS_POLLS) {
|
|
310
|
+
const message = `Scan job timed out after ${MAX_STATUS_POLLS * pollInterval} ms`;
|
|
311
|
+
throw new EsclScanError(
|
|
312
|
+
message,
|
|
313
|
+
pages.length > 0 ? this.combinePages(pages, false, message) : undefined
|
|
314
|
+
);
|
|
180
315
|
}
|
|
181
|
-
|
|
182
316
|
await plugins.smartdelay.delayFor(pollInterval);
|
|
183
|
-
attempts++;
|
|
184
317
|
}
|
|
185
318
|
|
|
186
|
-
if (
|
|
187
|
-
|
|
319
|
+
if (pages.length === MAX_SCAN_DOCUMENTS) {
|
|
320
|
+
const message = `Scan exceeded the safety limit of ${MAX_SCAN_DOCUMENTS} documents`;
|
|
321
|
+
throw new EsclScanError(message, this.combinePages(pages, false, message));
|
|
322
|
+
}
|
|
323
|
+
if (pages.length === 0) {
|
|
324
|
+
throw new Error('Scan completed without returning a document');
|
|
188
325
|
}
|
|
189
326
|
|
|
190
|
-
|
|
191
|
-
|
|
327
|
+
return this.combinePages(pages, true);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private combinePages(pages: IScanResult[], isComplete: boolean, jobStateReason?: string): IScanResult {
|
|
331
|
+
const first = pages[0];
|
|
332
|
+
return {
|
|
333
|
+
...first,
|
|
334
|
+
pageCount: pages.length,
|
|
335
|
+
pages: pages.map((page) => ({
|
|
336
|
+
data: page.data,
|
|
337
|
+
width: page.width,
|
|
338
|
+
height: page.height,
|
|
339
|
+
mimeType: page.mimeType,
|
|
340
|
+
})),
|
|
341
|
+
isComplete,
|
|
342
|
+
jobStateReason,
|
|
343
|
+
settingsWarnings: unique(pages.flatMap((page) => page.settingsWarnings ?? [])),
|
|
344
|
+
};
|
|
192
345
|
}
|
|
193
346
|
|
|
194
347
|
/**
|
|
195
348
|
* Download scanned document
|
|
196
349
|
*/
|
|
197
350
|
public async downloadScan(jobUri: string, options: IScanOptions): Promise<IScanResult> {
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (response.status !== 200) {
|
|
203
|
-
throw new Error(`Failed to download scan: HTTP ${response.status}`);
|
|
204
|
-
}
|
|
351
|
+
const result = await this.downloadNextDocument(jobUri, options);
|
|
352
|
+
if (!result) throw new Error('No scanned document is available');
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
205
355
|
|
|
206
|
-
|
|
207
|
-
const
|
|
356
|
+
private async downloadNextDocument(jobUri: string, options: IScanOptions): Promise<IScanResult | null> {
|
|
357
|
+
const downloadUrl = new URL('NextDocument', this.resolveJobUri(jobUri).replace(/\/?$/, '/'));
|
|
358
|
+
const response = await fetch(downloadUrl, {
|
|
359
|
+
method: 'GET',
|
|
360
|
+
signal: AbortSignal.timeout(DOCUMENT_REQUEST_TIMEOUT_MS),
|
|
361
|
+
});
|
|
208
362
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const height = parseInt(response.headers.get('x-image-height') || '0') || 0;
|
|
363
|
+
if ([404, 409, 410, 503].includes(response.status)) return null;
|
|
364
|
+
if (response.status !== 200) throw new Error(`Failed to download scan: HTTP ${response.status}`);
|
|
212
365
|
|
|
213
366
|
const arrayBuffer = await response.arrayBuffer();
|
|
214
367
|
const data = Buffer.from(arrayBuffer);
|
|
368
|
+
const requestedFormat = options.format ?? 'jpeg';
|
|
369
|
+
const contentType = response.headers.get('content-type') ?? FORMAT_MIME_MAP[requestedFormat];
|
|
370
|
+
const format = formatFromMimeType(contentType, requestedFormat);
|
|
371
|
+
const jpeg = format === 'jpeg' ? readJpegMetadata(data) : {};
|
|
372
|
+
const width = parseInt(response.headers.get('x-image-width') || '0') || jpeg.width || 0;
|
|
373
|
+
const height = parseInt(response.headers.get('x-image-height') || '0') || jpeg.height || 0;
|
|
374
|
+
const reportedResolution = parseInt(response.headers.get('x-image-resolution') || '0')
|
|
375
|
+
|| jpeg.resolution;
|
|
376
|
+
const resolution = reportedResolution || options.resolution || 300;
|
|
377
|
+
const settingsWarnings: string[] = [];
|
|
378
|
+
if (format !== requestedFormat) {
|
|
379
|
+
settingsWarnings.push(`Requested ${requestedFormat} output but scanner returned ${format}`);
|
|
380
|
+
}
|
|
381
|
+
if (reportedResolution && options.resolution && reportedResolution !== options.resolution) {
|
|
382
|
+
settingsWarnings.push(`Requested ${options.resolution} dpi but scanner returned ${reportedResolution} dpi`);
|
|
383
|
+
}
|
|
215
384
|
|
|
216
385
|
return {
|
|
217
386
|
data: data,
|
|
218
387
|
format: format,
|
|
219
388
|
width: width,
|
|
220
389
|
height: height,
|
|
221
|
-
resolution
|
|
390
|
+
resolution,
|
|
222
391
|
colorMode: options.colorMode ?? 'color',
|
|
223
392
|
mimeType: contentType,
|
|
393
|
+
settingsWarnings,
|
|
394
|
+
pageCount: 1,
|
|
395
|
+
pages: [{ data, width, height, mimeType: contentType }],
|
|
224
396
|
};
|
|
225
397
|
}
|
|
226
398
|
|
|
@@ -228,7 +400,10 @@ export class EsclProtocol {
|
|
|
228
400
|
* Cancel a scan job
|
|
229
401
|
*/
|
|
230
402
|
public async cancelJob(jobUri: string): Promise<void> {
|
|
231
|
-
const response = await fetch(jobUri, {
|
|
403
|
+
const response = await fetch(this.resolveJobUri(jobUri), {
|
|
404
|
+
method: 'DELETE',
|
|
405
|
+
signal: AbortSignal.timeout(CONTROL_REQUEST_TIMEOUT_MS),
|
|
406
|
+
});
|
|
232
407
|
|
|
233
408
|
// 204 No Content or 200 OK are both acceptable
|
|
234
409
|
if (response.status !== 200 && response.status !== 204) {
|
|
@@ -236,6 +411,10 @@ export class EsclProtocol {
|
|
|
236
411
|
}
|
|
237
412
|
}
|
|
238
413
|
|
|
414
|
+
public async cancelCurrentScan(): Promise<void> {
|
|
415
|
+
if (this.activeJobUri) await this.cancelJob(this.activeJobUri);
|
|
416
|
+
}
|
|
417
|
+
|
|
239
418
|
/**
|
|
240
419
|
* Build XML scan settings
|
|
241
420
|
*/
|
|
@@ -243,131 +422,108 @@ export class EsclProtocol {
|
|
|
243
422
|
const resolution = options.resolution ?? 300;
|
|
244
423
|
const colorMode = COLOR_MODE_MAP[options.colorMode ?? 'color'];
|
|
245
424
|
const format = FORMAT_MIME_MAP[options.format ?? 'jpeg'];
|
|
246
|
-
const
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
425
|
+
const scanSource = options.source ?? 'flatbed';
|
|
426
|
+
const source = scanSource === 'flatbed' ? 'Platen' : 'Feeder';
|
|
427
|
+
const intent = this.mapIntent(options.intent ?? 'document');
|
|
428
|
+
const toUnits = (mm: number) => Math.round((mm / 25.4) * 300);
|
|
429
|
+
const sourceCapabilities = scanSource === 'flatbed'
|
|
430
|
+
? this.capabilities?.platen
|
|
431
|
+
: scanSource === 'adf-duplex'
|
|
432
|
+
? this.capabilities?.adfDuplex
|
|
433
|
+
: this.capabilities?.adf;
|
|
434
|
+
const requestedArea = options.area ?? { x: 0, y: 0, width: 210, height: 297 };
|
|
435
|
+
const maxWidth = sourceCapabilities?.maxWidth ?? 215.9;
|
|
436
|
+
const maxHeight = sourceCapabilities?.maxHeight ?? (scanSource === 'flatbed' ? 296.926 : 355.6);
|
|
437
|
+
const area = {
|
|
438
|
+
x: Math.max(0, requestedArea.x),
|
|
439
|
+
y: Math.max(0, requestedArea.y),
|
|
440
|
+
width: Math.min(requestedArea.width, maxWidth - Math.max(0, requestedArea.x)),
|
|
441
|
+
height: Math.min(requestedArea.height, maxHeight - Math.max(0, requestedArea.y)),
|
|
442
|
+
};
|
|
443
|
+
const settings: TXmlRecord = {
|
|
444
|
+
'@_xmlns:scan': NAMESPACES.scan,
|
|
445
|
+
'@_xmlns:pwg': NAMESPACES.pwg,
|
|
446
|
+
'pwg:Version': '2.0',
|
|
447
|
+
'scan:Intent': intent,
|
|
448
|
+
'pwg:ScanRegions': {
|
|
449
|
+
'pwg:ScanRegion': {
|
|
450
|
+
'pwg:ContentRegionUnits': 'escl:ThreeHundredthsOfInches',
|
|
451
|
+
'pwg:XOffset': toUnits(area.x),
|
|
452
|
+
'pwg:YOffset': toUnits(area.y),
|
|
453
|
+
'pwg:Width': toUnits(area.width),
|
|
454
|
+
'pwg:Height': toUnits(area.height),
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
'pwg:InputSource': source,
|
|
458
|
+
'scan:ColorMode': colorMode,
|
|
459
|
+
'pwg:DocumentFormat': format,
|
|
460
|
+
'scan:DocumentFormatExt': format,
|
|
461
|
+
'scan:XResolution': resolution,
|
|
462
|
+
'scan:YResolution': resolution,
|
|
463
|
+
};
|
|
464
|
+
if (scanSource !== 'flatbed') settings['scan:Duplex'] = scanSource === 'adf-duplex';
|
|
283
465
|
if (options.format === 'jpeg' && options.quality) {
|
|
284
|
-
|
|
285
|
-
<scan:CompressionFactor>${100 - options.quality}</scan:CompressionFactor>`;
|
|
466
|
+
settings['scan:CompressionFactor'] = 100 - options.quality;
|
|
286
467
|
}
|
|
468
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n${xmlBuilder.build({ 'scan:ScanSettings': settings })}`;
|
|
469
|
+
}
|
|
287
470
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
return
|
|
471
|
+
private mapIntent(intent: IScanOptions['intent']): string {
|
|
472
|
+
if (intent === 'photo') return 'Photo';
|
|
473
|
+
if (intent === 'preview') return 'Preview';
|
|
474
|
+
return 'Document';
|
|
292
475
|
}
|
|
293
476
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
switch (source) {
|
|
299
|
-
case 'flatbed':
|
|
300
|
-
return 'Platen';
|
|
301
|
-
case 'adf':
|
|
302
|
-
return 'Feeder';
|
|
303
|
-
case 'adf-duplex':
|
|
304
|
-
return 'Duplex';
|
|
305
|
-
default:
|
|
306
|
-
return 'Platen';
|
|
307
|
-
}
|
|
477
|
+
private nextDocumentDelay(source: IScanOptions['source'], requestDuration: number): number {
|
|
478
|
+
const isBrother = this.capabilities?.makeAndModel.toLowerCase().startsWith('brother ');
|
|
479
|
+
if (!isBrother || source === 'flatbed') return 0;
|
|
480
|
+
return Math.min(Math.round(requestDuration * 0.5), 1000);
|
|
308
481
|
}
|
|
309
482
|
|
|
310
483
|
/**
|
|
311
484
|
* Parse capabilities XML response
|
|
312
485
|
*/
|
|
313
486
|
private parseCapabilities(body: string): IEsclCapabilities {
|
|
314
|
-
const
|
|
487
|
+
const document = asRecord(xmlParser.parse(body));
|
|
488
|
+
const root = asRecord(document.ScannerCapabilities);
|
|
489
|
+
const platen = asRecord(asRecord(root.Platen).PlatenInputCaps);
|
|
490
|
+
const adfRoot = asRecord(root.Adf);
|
|
491
|
+
const adf = asRecord(adfRoot.AdfSimplexInputCaps);
|
|
492
|
+
const adfDuplex = asRecord(adfRoot.AdfDuplexInputCaps);
|
|
315
493
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (match[1]?.trim()) {
|
|
329
|
-
matches.push(match[1].trim());
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
return matches;
|
|
333
|
-
};
|
|
334
|
-
|
|
335
|
-
const parseResolutions = (): number[] => {
|
|
336
|
-
const resolutions = getAllTagContents('XResolution');
|
|
337
|
-
return [...new Set(resolutions.map((r) => parseInt(r)).filter((r) => !isNaN(r)))];
|
|
494
|
+
return {
|
|
495
|
+
version: asText(root.Version) || '2.0',
|
|
496
|
+
makeAndModel: asText(root.MakeAndModel) || asText(root.Make) || 'Unknown',
|
|
497
|
+
serialNumber: asText(root.SerialNumber) || undefined,
|
|
498
|
+
uuid: asText(root.UUID) || undefined,
|
|
499
|
+
adminUri: asText(root.AdminURI) || undefined,
|
|
500
|
+
iconUri: asText(root.IconURI) || undefined,
|
|
501
|
+
platen: Object.keys(platen).length > 0 ? this.parseInputSource(platen, 215.9, 297) : undefined,
|
|
502
|
+
adf: Object.keys(adf).length > 0 ? this.parseInputSource(adf, 215.9, 355.6) : undefined,
|
|
503
|
+
adfDuplex: Object.keys(adfDuplex).length > 0
|
|
504
|
+
? this.parseInputSource(adfDuplex, 215.9, 355.6)
|
|
505
|
+
: undefined,
|
|
338
506
|
};
|
|
507
|
+
}
|
|
339
508
|
|
|
509
|
+
private parseInputSource(source: TXmlRecord, defaultWidth: number, defaultHeight: number) {
|
|
510
|
+
const resolutions = collectValues(source, 'XResolution')
|
|
511
|
+
.map((value) => asInteger(value))
|
|
512
|
+
.filter((value) => value > 0);
|
|
513
|
+
const colorModes = collectValues(source, 'ColorMode').map(asText).filter(Boolean);
|
|
514
|
+
const documentFormats = [
|
|
515
|
+
...collectValues(source, 'DocumentFormat'),
|
|
516
|
+
...collectValues(source, 'DocumentFormatExt'),
|
|
517
|
+
].map(asText).filter(Boolean);
|
|
340
518
|
return {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
minWidth: parseInt(getTagContent('MinWidth')) || 0,
|
|
350
|
-
maxWidth: parseInt(getTagContent('MaxWidth')) || 2550,
|
|
351
|
-
minHeight: parseInt(getTagContent('MinHeight')) || 0,
|
|
352
|
-
maxHeight: parseInt(getTagContent('MaxHeight')) || 3508,
|
|
353
|
-
maxScanRegions: parseInt(getTagContent('MaxScanRegions')) || 1,
|
|
354
|
-
supportedResolutions: parseResolutions(),
|
|
355
|
-
colorModes: getAllTagContents('ColorMode'),
|
|
356
|
-
documentFormats: getAllTagContents('DocumentFormatExt'),
|
|
357
|
-
}
|
|
358
|
-
: undefined,
|
|
359
|
-
adf: xml.includes('Adf') || xml.includes('ADF') || xml.includes('Feeder')
|
|
360
|
-
? {
|
|
361
|
-
minWidth: 0,
|
|
362
|
-
maxWidth: 2550,
|
|
363
|
-
minHeight: 0,
|
|
364
|
-
maxHeight: 4200,
|
|
365
|
-
maxScanRegions: 1,
|
|
366
|
-
supportedResolutions: parseResolutions(),
|
|
367
|
-
colorModes: getAllTagContents('ColorMode'),
|
|
368
|
-
documentFormats: getAllTagContents('DocumentFormatExt'),
|
|
369
|
-
}
|
|
370
|
-
: undefined,
|
|
519
|
+
minWidth: esclUnitsToMillimetres(source.MinWidth, 0),
|
|
520
|
+
maxWidth: esclUnitsToMillimetres(source.MaxWidth, defaultWidth),
|
|
521
|
+
minHeight: esclUnitsToMillimetres(source.MinHeight, 0),
|
|
522
|
+
maxHeight: esclUnitsToMillimetres(source.MaxHeight, defaultHeight),
|
|
523
|
+
maxScanRegions: asInteger(source.MaxScanRegions, 1),
|
|
524
|
+
supportedResolutions: unique(resolutions),
|
|
525
|
+
colorModes: unique(colorModes),
|
|
526
|
+
documentFormats: unique(documentFormats),
|
|
371
527
|
};
|
|
372
528
|
}
|
|
373
529
|
|
|
@@ -375,53 +531,52 @@ export class EsclProtocol {
|
|
|
375
531
|
* Parse status XML response
|
|
376
532
|
*/
|
|
377
533
|
private parseStatus(body: string): IEsclScanStatus {
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const match = jobXml.match(regex);
|
|
397
|
-
return match?.[1]?.trim() ?? '';
|
|
398
|
-
};
|
|
399
|
-
|
|
400
|
-
jobs.push({
|
|
401
|
-
jobUri: getJobTag('JobUri') || getJobTag('JobURI') || '',
|
|
402
|
-
jobUuid: getJobTag('JobUuid') || getJobTag('JobUUID') || '',
|
|
403
|
-
age: parseInt(getJobTag('Age')) || 0,
|
|
404
|
-
imagesCompleted: parseInt(getJobTag('ImagesCompleted')) || 0,
|
|
405
|
-
imagesToTransfer: parseInt(getJobTag('ImagesToTransfer')) || 0,
|
|
406
|
-
jobState: (getJobTag('JobState') as IEsclJobInfo['jobState']) || 'Pending',
|
|
407
|
-
jobStateReason: getJobTag('JobStateReason') || undefined,
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
}
|
|
534
|
+
const document = asRecord(xmlParser.parse(body));
|
|
535
|
+
const root = asRecord(document.ScannerStatus);
|
|
536
|
+
const state = asText(root.State) || asText(root.ScannerState) || 'Idle';
|
|
537
|
+
const rawAdfState = asText(root.AdfState).replace(/^ScannerAdf/, '');
|
|
538
|
+
const jobsRoot = asRecord(root.Jobs);
|
|
539
|
+
const jobs = asArray(jobsRoot.JobInfo).map((value): IEsclJobInfo => {
|
|
540
|
+
const job = asRecord(value);
|
|
541
|
+
const reason = collectValues(job.JobStateReasons, 'JobStateReason').map(asText).find(Boolean);
|
|
542
|
+
return {
|
|
543
|
+
jobUri: asText(job.JobUri) || asText(job.JobURI),
|
|
544
|
+
jobUuid: asText(job.JobUuid) || asText(job.JobUUID),
|
|
545
|
+
age: asInteger(job.Age),
|
|
546
|
+
imagesCompleted: asInteger(job.ImagesCompleted),
|
|
547
|
+
imagesToTransfer: asInteger(job.ImagesToTransfer),
|
|
548
|
+
jobState: (asText(job.JobState) as IEsclJobInfo['jobState']) || 'Pending',
|
|
549
|
+
jobStateReason: reason || undefined,
|
|
550
|
+
};
|
|
551
|
+
});
|
|
411
552
|
|
|
412
553
|
return {
|
|
413
554
|
state: state as IEsclScanStatus['state'],
|
|
414
|
-
adfState:
|
|
555
|
+
adfState: rawAdfState ? rawAdfState as IEsclScanStatus['adfState'] : undefined,
|
|
415
556
|
jobs: jobs.length > 0 ? jobs : undefined,
|
|
416
557
|
};
|
|
417
558
|
}
|
|
418
559
|
|
|
560
|
+
private resolveJobUri(jobUri: string): string {
|
|
561
|
+
return new URL(jobUri, `${this.baseUrl}/`).toString();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private findJob(status: IEsclScanStatus, jobUri: string): IEsclJobInfo | undefined {
|
|
565
|
+
const resolved = new URL(this.resolveJobUri(jobUri));
|
|
566
|
+
const uuid = resolved.pathname.split('/').filter(Boolean).at(-1) || '';
|
|
567
|
+
return status.jobs?.find((job) => {
|
|
568
|
+
const candidate = new URL(job.jobUri, `${this.baseUrl}/`);
|
|
569
|
+
return candidate.pathname === resolved.pathname || job.jobUuid.endsWith(uuid);
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
419
573
|
/**
|
|
420
574
|
* Perform a complete scan operation
|
|
421
575
|
*/
|
|
422
576
|
public async scan(options: IScanOptions): Promise<IScanResult> {
|
|
423
577
|
// Submit the job
|
|
424
578
|
const jobUri = await this.submitScanJob(options);
|
|
579
|
+
this.activeJobUri = jobUri;
|
|
425
580
|
|
|
426
581
|
try {
|
|
427
582
|
// Wait for completion and download
|
|
@@ -434,6 +589,8 @@ export class EsclProtocol {
|
|
|
434
589
|
// Ignore cancel errors
|
|
435
590
|
}
|
|
436
591
|
throw error;
|
|
592
|
+
} finally {
|
|
593
|
+
this.activeJobUri = null;
|
|
437
594
|
}
|
|
438
595
|
}
|
|
439
596
|
}
|