@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.
Files changed (78) hide show
  1. package/.gitea/workflows/default_tags.yaml +0 -21
  2. package/.smartconfig.json +69 -0
  3. package/changelog.md +38 -1
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/device/device.classes.device.d.ts +2 -2
  6. package/dist_ts/device/device.classes.device.js +3 -3
  7. package/dist_ts/devicemanager.classes.devicemanager.d.ts +17 -4
  8. package/dist_ts/devicemanager.classes.devicemanager.js +99 -11
  9. package/dist_ts/discovery/discovery.classes.mdns.js +46 -11
  10. package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +5 -1
  11. package/dist_ts/discovery/discovery.classes.networkscanner.js +44 -3
  12. package/dist_ts/discovery/discovery.classes.ssdp.d.ts +52 -21
  13. package/dist_ts/discovery/discovery.classes.ssdp.js +407 -117
  14. package/dist_ts/events/events.observable.d.ts +15 -0
  15. package/dist_ts/events/events.observable.js +22 -0
  16. package/dist_ts/events/index.d.ts +1 -0
  17. package/dist_ts/events/index.js +2 -0
  18. package/dist_ts/factories/index.js +7 -5
  19. package/dist_ts/features/feature.abstract.d.ts +2 -2
  20. package/dist_ts/features/feature.abstract.js +3 -3
  21. package/dist_ts/features/feature.print.d.ts +3 -0
  22. package/dist_ts/features/feature.print.js +17 -2
  23. package/dist_ts/features/feature.scan.d.ts +5 -2
  24. package/dist_ts/features/feature.scan.js +98 -27
  25. package/dist_ts/index.d.ts +4 -2
  26. package/dist_ts/index.js +4 -2
  27. package/dist_ts/interfaces/feature.interfaces.d.ts +22 -2
  28. package/dist_ts/interfaces/index.d.ts +22 -4
  29. package/dist_ts/interfaces/index.js +1 -1
  30. package/dist_ts/plugins.d.ts +6 -15
  31. package/dist_ts/plugins.js +7 -19
  32. package/dist_ts/protocols/index.d.ts +2 -1
  33. package/dist_ts/protocols/index.js +4 -2
  34. package/dist_ts/protocols/protocol.brother.d.ts +67 -0
  35. package/dist_ts/protocols/protocol.brother.js +560 -0
  36. package/dist_ts/protocols/protocol.escl.d.ts +13 -4
  37. package/dist_ts/protocols/protocol.escl.js +345 -178
  38. package/dist_ts/protocols/protocol.ipp.d.ts +2 -0
  39. package/dist_ts/protocols/protocol.ipp.js +22 -2
  40. package/dist_ts/providers/index.d.ts +1 -0
  41. package/dist_ts/providers/index.js +2 -0
  42. package/dist_ts/providers/provider.smb.d.ts +60 -0
  43. package/dist_ts/providers/provider.smb.js +224 -0
  44. package/license.md +21 -0
  45. package/package.json +15 -20
  46. package/pnpm-workspace.yaml +6 -0
  47. package/readme.md +157 -78
  48. package/test/test.brother.node.ts +306 -0
  49. package/test/test.escl.node.ts +233 -0
  50. package/test/test.ipp.node.ts +75 -0
  51. package/test/{test.ts → test.node.ts} +37 -45
  52. package/test/test.smb.node.ts +123 -0
  53. package/test/test.ssdp.node.ts +978 -0
  54. package/ts/00_commitinfo_data.ts +1 -1
  55. package/ts/device/device.classes.device.ts +2 -2
  56. package/ts/devicemanager.classes.devicemanager.ts +125 -12
  57. package/ts/discovery/discovery.classes.mdns.ts +40 -11
  58. package/ts/discovery/discovery.classes.networkscanner.ts +51 -2
  59. package/ts/discovery/discovery.classes.ssdp.ts +493 -130
  60. package/ts/events/events.observable.ts +31 -0
  61. package/ts/events/index.ts +4 -0
  62. package/ts/factories/index.ts +7 -5
  63. package/ts/features/feature.abstract.ts +2 -2
  64. package/ts/features/feature.print.ts +16 -1
  65. package/ts/features/feature.scan.ts +93 -27
  66. package/ts/index.ts +21 -0
  67. package/ts/interfaces/feature.interfaces.ts +23 -2
  68. package/ts/interfaces/index.ts +23 -4
  69. package/ts/plugins.ts +6 -25
  70. package/ts/protocols/index.ts +11 -1
  71. package/ts/protocols/protocol.brother.ts +648 -0
  72. package/ts/protocols/protocol.escl.ts +348 -191
  73. package/ts/protocols/protocol.ipp.ts +24 -1
  74. package/ts/providers/index.ts +7 -0
  75. package/ts/providers/provider.smb.ts +326 -0
  76. package/dist_ts/protocols/protocol.ipp.old.d.ts +0 -45
  77. package/dist_ts/protocols/protocol.ipp.old.js +0 -284
  78. 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 = { method };
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 POST with body since SmartRequest API may not support raw body
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; charset=utf-8',
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 = 500
266
+ pollInterval: number = STATUS_POLL_INTERVAL_MS
154
267
  ): Promise<IScanResult> {
155
- // Try direct download first - this triggers the scan on many scanners
156
- // (including Brother) and blocks until the scan completes
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
- // Fall back to polling for scanners that need it
167
- let attempts = 0;
168
- const maxAttempts = 120; // 60 seconds max
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 = status.jobs?.find((j) => jobUri.includes(j.jobUuid) || j.jobUri === jobUri);
173
-
174
- if (job) {
175
- if (job.jobState === 'Completed') {
176
- break;
177
- } else if (job.jobState === 'Canceled' || job.jobState === 'Aborted') {
178
- throw new Error(`Scan job ${job.jobState}: ${job.jobStateReason || 'Unknown reason'}`);
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 (attempts >= maxAttempts) {
187
- throw new Error('Scan job timed out');
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
- // Download the scanned document
191
- return this.downloadScan(jobUri, options);
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 downloadUrl = `${jobUri}/NextDocument`;
199
-
200
- const response = await fetch(downloadUrl, { method: 'GET' });
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
- const format = options.format ?? 'jpeg';
207
- const contentType = response.headers.get('content-type') ?? FORMAT_MIME_MAP[format];
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
- // Get image dimensions from headers if available
210
- const width = parseInt(response.headers.get('x-image-width') || '0') || 0;
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: options.resolution ?? 300,
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, { method: 'DELETE' });
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 source = this.mapSource(options.source ?? 'flatbed');
247
- const intent = options.intent ?? 'TextAndPhoto';
248
-
249
- let xml = `<?xml version="1.0" encoding="UTF-8"?>
250
- <scan:ScanSettings xmlns:scan="${NAMESPACES.scan}" xmlns:pwg="${NAMESPACES.pwg}">
251
- <pwg:Version>2.0</pwg:Version>
252
- <scan:Intent>${intent}</scan:Intent>
253
- <pwg:ScanRegions>
254
- <pwg:ScanRegion>`;
255
-
256
- if (options.area) {
257
- // Convert mm to 300ths of an inch (eSCL uses 300dpi as base unit)
258
- const toUnits = (mm: number) => Math.round((mm / 25.4) * 300);
259
- xml += `
260
- <pwg:XOffset>${toUnits(options.area.x)}</pwg:XOffset>
261
- <pwg:YOffset>${toUnits(options.area.y)}</pwg:YOffset>
262
- <pwg:Width>${toUnits(options.area.width)}</pwg:Width>
263
- <pwg:Height>${toUnits(options.area.height)}</pwg:Height>`;
264
- } else {
265
- // Full page (A4 default: 210x297mm)
266
- xml += `
267
- <pwg:XOffset>0</pwg:XOffset>
268
- <pwg:YOffset>0</pwg:YOffset>
269
- <pwg:Width>2480</pwg:Width>
270
- <pwg:Height>3508</pwg:Height>`;
271
- }
272
-
273
- xml += `
274
- <pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits>
275
- </pwg:ScanRegion>
276
- </pwg:ScanRegions>
277
- <scan:DocumentFormatExt>${format}</scan:DocumentFormatExt>
278
- <scan:XResolution>${resolution}</scan:XResolution>
279
- <scan:YResolution>${resolution}</scan:YResolution>
280
- <scan:ColorMode>${colorMode}</scan:ColorMode>
281
- <scan:InputSource>${source}</scan:InputSource>`;
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
- xml += `
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
- xml += `
289
- </scan:ScanSettings>`;
290
-
291
- return xml;
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
- * Map source to eSCL format
296
- */
297
- private mapSource(source: string): string {
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 xml = body;
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
- // Simple XML parsing without full XML parser
317
- const getTagContent = (tag: string): string => {
318
- const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'i');
319
- const match = xml.match(regex);
320
- return match?.[1]?.trim() ?? '';
321
- };
322
-
323
- const getAllTagContents = (tag: string): string[] => {
324
- const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'gi');
325
- const matches: string[] = [];
326
- let match;
327
- while ((match = regex.exec(xml)) !== null) {
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
- version: getTagContent('Version') || '2.0',
342
- makeAndModel: getTagContent('MakeAndModel') || getTagContent('Make') || 'Unknown',
343
- serialNumber: getTagContent('SerialNumber') || undefined,
344
- uuid: getTagContent('UUID') || undefined,
345
- adminUri: getTagContent('AdminURI') || undefined,
346
- iconUri: getTagContent('IconURI') || undefined,
347
- platen: xml.includes('Platen')
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 xml = body;
379
-
380
- const getTagContent = (tag: string): string => {
381
- const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'i');
382
- const match = xml.match(regex);
383
- return match?.[1]?.trim() ?? '';
384
- };
385
-
386
- const state = getTagContent('State') || getTagContent('ScannerState') || 'Idle';
387
- const adfState = getTagContent('AdfState') || undefined;
388
-
389
- // Parse jobs if present
390
- const jobs: IEsclJobInfo[] = [];
391
- const jobMatches = xml.match(/<[^:]*:?JobInfo[^>]*>[\s\S]*?<\/[^:]*:?JobInfo>/gi);
392
- if (jobMatches) {
393
- for (const jobXml of jobMatches) {
394
- const getJobTag = (tag: string): string => {
395
- const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'i');
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: adfState as IEsclScanStatus['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
  }