@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
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import type {
|
|
3
|
+
IScanArea,
|
|
4
|
+
IScanCapabilities,
|
|
5
|
+
IScanResult,
|
|
6
|
+
TColorMode,
|
|
7
|
+
TScanFormat,
|
|
8
|
+
TScanSource,
|
|
9
|
+
} from '../interfaces/index.js';
|
|
10
|
+
|
|
11
|
+
const BROTHER_SCAN_PORT = 54921;
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
13
|
+
const DEFAULT_SCAN_TIMEOUT_MS = 10 * 60_000;
|
|
14
|
+
const DEFAULT_BUSY_RETRY_INTERVAL_MS = 1_000;
|
|
15
|
+
const DEFAULT_BUSY_TIMEOUT_MS = 30_000;
|
|
16
|
+
const MAX_PAGES = 500;
|
|
17
|
+
const MAX_PAGE_BYTES = 256 * 1024 * 1024;
|
|
18
|
+
const TERMINATOR = 0x80;
|
|
19
|
+
|
|
20
|
+
export type TBrotherScanErrorCode =
|
|
21
|
+
| 'busy'
|
|
22
|
+
| 'cancelled'
|
|
23
|
+
| 'cover-open'
|
|
24
|
+
| 'device-stuck'
|
|
25
|
+
| 'document-jam'
|
|
26
|
+
| 'invalid-response'
|
|
27
|
+
| 'no-document'
|
|
28
|
+
| 'service-error'
|
|
29
|
+
| 'settings-rejected'
|
|
30
|
+
| 'timeout'
|
|
31
|
+
| 'unsupported';
|
|
32
|
+
|
|
33
|
+
export class BrotherScanError extends Error {
|
|
34
|
+
constructor(
|
|
35
|
+
message: string,
|
|
36
|
+
public readonly code: TBrotherScanErrorCode,
|
|
37
|
+
public readonly statusCode?: number
|
|
38
|
+
) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'BrotherScanError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface IBrotherScanProtocolOptions {
|
|
45
|
+
timeout?: number;
|
|
46
|
+
scanTimeout?: number;
|
|
47
|
+
busyRetryInterval?: number;
|
|
48
|
+
busyTimeout?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface IBrotherScanLease {
|
|
52
|
+
resolutionX: number;
|
|
53
|
+
resolutionY: number;
|
|
54
|
+
adfStatus: number;
|
|
55
|
+
planeWidth: number;
|
|
56
|
+
width: number;
|
|
57
|
+
planeHeight: number;
|
|
58
|
+
height: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface IBrotherScanOptions {
|
|
62
|
+
resolution: number;
|
|
63
|
+
format: TScanFormat;
|
|
64
|
+
colorMode: TColorMode;
|
|
65
|
+
source: TScanSource;
|
|
66
|
+
area: IScanArea;
|
|
67
|
+
quality?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type TFrameMode = 'simple' | 'wrapped';
|
|
71
|
+
|
|
72
|
+
class SocketBuffer {
|
|
73
|
+
private buffer = Buffer.alloc(0);
|
|
74
|
+
private error: Error | null = null;
|
|
75
|
+
private ended = false;
|
|
76
|
+
|
|
77
|
+
constructor(private readonly socket: plugins.net.Socket) {
|
|
78
|
+
socket.on('data', (data: Buffer) => {
|
|
79
|
+
this.buffer = Buffer.concat([this.buffer, data]);
|
|
80
|
+
});
|
|
81
|
+
socket.on('error', (error) => {
|
|
82
|
+
this.error = error;
|
|
83
|
+
});
|
|
84
|
+
socket.on('end', () => {
|
|
85
|
+
this.ended = true;
|
|
86
|
+
});
|
|
87
|
+
socket.on('close', () => {
|
|
88
|
+
this.ended = true;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
public discard(): void {
|
|
93
|
+
this.buffer = Buffer.alloc(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public async readExactly(length: number, timeout: number): Promise<Buffer> {
|
|
97
|
+
await this.waitForLength(length, timeout);
|
|
98
|
+
const result = this.buffer.subarray(0, length);
|
|
99
|
+
this.buffer = this.buffer.subarray(length);
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
public async peekExactly(length: number, timeout: number): Promise<Buffer> {
|
|
104
|
+
await this.waitForLength(length, timeout);
|
|
105
|
+
return this.buffer.subarray(0, length);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public async readUntil(delimiter: Buffer, timeout: number, maxLength: number): Promise<Buffer> {
|
|
109
|
+
const deadline = Date.now() + timeout;
|
|
110
|
+
while (true) {
|
|
111
|
+
const index = this.buffer.indexOf(delimiter);
|
|
112
|
+
if (index >= 0) {
|
|
113
|
+
const end = index + delimiter.length;
|
|
114
|
+
const result = this.buffer.subarray(0, end);
|
|
115
|
+
this.buffer = this.buffer.subarray(end);
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
if (this.buffer.length > maxLength) {
|
|
119
|
+
throw new BrotherScanError('Brother scanner response exceeded the expected length', 'invalid-response');
|
|
120
|
+
}
|
|
121
|
+
await this.waitForData(deadline);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
public async readMatch(pattern: RegExp, timeout: number, maxLength: number): Promise<RegExpMatchArray> {
|
|
126
|
+
const deadline = Date.now() + timeout;
|
|
127
|
+
while (true) {
|
|
128
|
+
const match = this.buffer.toString('latin1').match(pattern);
|
|
129
|
+
if (match) return match;
|
|
130
|
+
if (this.buffer.length > maxLength) {
|
|
131
|
+
throw new BrotherScanError('Brother scanner lease response was malformed', 'invalid-response');
|
|
132
|
+
}
|
|
133
|
+
await this.waitForData(deadline);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async waitForLength(length: number, timeout: number): Promise<void> {
|
|
138
|
+
const deadline = Date.now() + timeout;
|
|
139
|
+
while (this.buffer.length < length) {
|
|
140
|
+
await this.waitForData(deadline);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async waitForData(deadline: number): Promise<void> {
|
|
145
|
+
if (this.error) throw this.error;
|
|
146
|
+
if (this.ended) {
|
|
147
|
+
throw new BrotherScanError('Brother scanner closed the connection', 'invalid-response');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const remaining = deadline - Date.now();
|
|
151
|
+
if (remaining <= 0) {
|
|
152
|
+
throw new BrotherScanError('Timed out waiting for the Brother scanner', 'timeout');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await new Promise<void>((resolve, reject) => {
|
|
156
|
+
const cleanup = () => {
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
this.socket.off('data', onData);
|
|
159
|
+
this.socket.off('error', onError);
|
|
160
|
+
this.socket.off('end', onEnd);
|
|
161
|
+
this.socket.off('close', onEnd);
|
|
162
|
+
};
|
|
163
|
+
const onData = () => {
|
|
164
|
+
cleanup();
|
|
165
|
+
resolve();
|
|
166
|
+
};
|
|
167
|
+
const onError = (error: Error) => {
|
|
168
|
+
cleanup();
|
|
169
|
+
reject(error);
|
|
170
|
+
};
|
|
171
|
+
const onEnd = () => {
|
|
172
|
+
cleanup();
|
|
173
|
+
reject(new BrotherScanError('Brother scanner closed the connection', 'invalid-response'));
|
|
174
|
+
};
|
|
175
|
+
const timer = setTimeout(() => {
|
|
176
|
+
cleanup();
|
|
177
|
+
reject(new BrotherScanError('Timed out waiting for the Brother scanner', 'timeout'));
|
|
178
|
+
}, remaining);
|
|
179
|
+
|
|
180
|
+
this.socket.once('data', onData);
|
|
181
|
+
this.socket.once('error', onError);
|
|
182
|
+
this.socket.once('end', onEnd);
|
|
183
|
+
this.socket.once('close', onEnd);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Brother's native network scan protocol on TCP 54921.
|
|
190
|
+
*
|
|
191
|
+
* Brother devices expose richer scan control here than they do through some
|
|
192
|
+
* eSCL implementations. The protocol returns one JPEG stream per sheet/side.
|
|
193
|
+
*/
|
|
194
|
+
export class BrotherScanProtocol {
|
|
195
|
+
private socket: plugins.net.Socket | null = null;
|
|
196
|
+
private reader: SocketBuffer | null = null;
|
|
197
|
+
private scanning = false;
|
|
198
|
+
private cancelled = false;
|
|
199
|
+
private readonly timeout: number;
|
|
200
|
+
private readonly scanTimeout: number;
|
|
201
|
+
private readonly busyRetryInterval: number;
|
|
202
|
+
private readonly busyTimeout: number;
|
|
203
|
+
|
|
204
|
+
constructor(
|
|
205
|
+
private readonly address: string,
|
|
206
|
+
private readonly port: number = BROTHER_SCAN_PORT,
|
|
207
|
+
options: IBrotherScanProtocolOptions = {}
|
|
208
|
+
) {
|
|
209
|
+
this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
210
|
+
this.scanTimeout = options.scanTimeout ?? DEFAULT_SCAN_TIMEOUT_MS;
|
|
211
|
+
this.busyRetryInterval = options.busyRetryInterval ?? DEFAULT_BUSY_RETRY_INTERVAL_MS;
|
|
212
|
+
this.busyTimeout = options.busyTimeout ?? DEFAULT_BUSY_TIMEOUT_MS;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
public async connect(): Promise<void> {
|
|
216
|
+
if (this.socket && !this.socket.destroyed) return;
|
|
217
|
+
|
|
218
|
+
const busyDeadline = Date.now() + this.busyTimeout;
|
|
219
|
+
while (true) {
|
|
220
|
+
try {
|
|
221
|
+
await this.connectOnce();
|
|
222
|
+
return;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
await this.disconnect();
|
|
225
|
+
if (
|
|
226
|
+
!(error instanceof BrotherScanError) ||
|
|
227
|
+
error.code !== 'busy' ||
|
|
228
|
+
Date.now() >= busyDeadline
|
|
229
|
+
) {
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
await delay(Math.min(this.busyRetryInterval, Math.max(0, busyDeadline - Date.now())));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async connectOnce(): Promise<void> {
|
|
238
|
+
const socket = new plugins.net.Socket();
|
|
239
|
+
socket.setNoDelay(true);
|
|
240
|
+
this.socket = socket;
|
|
241
|
+
this.reader = new SocketBuffer(socket);
|
|
242
|
+
|
|
243
|
+
await new Promise<void>((resolve, reject) => {
|
|
244
|
+
const timer = setTimeout(() => {
|
|
245
|
+
socket.destroy();
|
|
246
|
+
reject(new BrotherScanError('Timed out connecting to the Brother scanner', 'timeout'));
|
|
247
|
+
}, this.timeout);
|
|
248
|
+
const cleanup = () => {
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
socket.off('connect', onConnect);
|
|
251
|
+
socket.off('error', onError);
|
|
252
|
+
};
|
|
253
|
+
const onConnect = () => {
|
|
254
|
+
cleanup();
|
|
255
|
+
resolve();
|
|
256
|
+
};
|
|
257
|
+
const onError = (error: Error) => {
|
|
258
|
+
cleanup();
|
|
259
|
+
reject(error);
|
|
260
|
+
};
|
|
261
|
+
socket.once('connect', onConnect);
|
|
262
|
+
socket.once('error', onError);
|
|
263
|
+
socket.connect(this.port, this.address);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const banner = (await this.requireReader().readUntil(Buffer.from('\r\n'), this.timeout, 128))
|
|
267
|
+
.toString('ascii')
|
|
268
|
+
.trim();
|
|
269
|
+
if (banner.startsWith('+OK 200')) return;
|
|
270
|
+
if (banner.startsWith('-NG 401')) {
|
|
271
|
+
throw new BrotherScanError('Brother scanner is busy', 'busy');
|
|
272
|
+
}
|
|
273
|
+
throw new BrotherScanError(`Unexpected Brother scanner banner: ${banner}`, 'invalid-response');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
public async disconnect(): Promise<void> {
|
|
277
|
+
const socket = this.socket;
|
|
278
|
+
this.socket = null;
|
|
279
|
+
this.reader = null;
|
|
280
|
+
this.scanning = false;
|
|
281
|
+
if (!socket || socket.destroyed) return;
|
|
282
|
+
|
|
283
|
+
await new Promise<void>((resolve) => {
|
|
284
|
+
let settled = false;
|
|
285
|
+
const finish = () => {
|
|
286
|
+
if (settled) return;
|
|
287
|
+
settled = true;
|
|
288
|
+
clearTimeout(timer);
|
|
289
|
+
socket.off('close', finish);
|
|
290
|
+
socket.off('error', finish);
|
|
291
|
+
if (!socket.destroyed) socket.destroy();
|
|
292
|
+
resolve();
|
|
293
|
+
};
|
|
294
|
+
const timer = setTimeout(finish, 250);
|
|
295
|
+
socket.once('close', finish);
|
|
296
|
+
socket.once('error', finish);
|
|
297
|
+
socket.end();
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
public getCapabilities(): IScanCapabilities {
|
|
302
|
+
return {
|
|
303
|
+
resolutions: [100, 150, 200, 300, 600],
|
|
304
|
+
formats: ['jpeg'],
|
|
305
|
+
colorModes: ['color'],
|
|
306
|
+
sources: ['flatbed', 'adf', 'adf-duplex'],
|
|
307
|
+
maxWidth: 215.9,
|
|
308
|
+
maxHeight: 355.6,
|
|
309
|
+
minWidth: 10,
|
|
310
|
+
minHeight: 10,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
public async scan(options: IBrotherScanOptions): Promise<IScanResult> {
|
|
315
|
+
if (!this.socket || !this.reader) {
|
|
316
|
+
throw new Error('Brother scanner is not connected');
|
|
317
|
+
}
|
|
318
|
+
if (this.scanning) {
|
|
319
|
+
throw new BrotherScanError('A Brother scan is already in progress', 'busy');
|
|
320
|
+
}
|
|
321
|
+
this.validateOptions(options);
|
|
322
|
+
|
|
323
|
+
this.scanning = true;
|
|
324
|
+
this.cancelled = false;
|
|
325
|
+
try {
|
|
326
|
+
await this.selectSource(options.source);
|
|
327
|
+
const lease = await this.requestLease(options.resolution);
|
|
328
|
+
this.assertLease(lease, options);
|
|
329
|
+
|
|
330
|
+
const area = this.resolveScanArea(options.area, lease);
|
|
331
|
+
await this.writeCommand([
|
|
332
|
+
'\x1bX',
|
|
333
|
+
`R=${lease.resolutionX},${lease.resolutionY}`,
|
|
334
|
+
'M=CGRAY',
|
|
335
|
+
'C=JPEG',
|
|
336
|
+
'J=MID',
|
|
337
|
+
'B=50',
|
|
338
|
+
'N=50',
|
|
339
|
+
`A=${area.x},${area.y},${area.x + area.width},${area.y + area.height}`,
|
|
340
|
+
options.source === 'adf-duplex' ? 'D=DUP' : 'D=SIN',
|
|
341
|
+
]);
|
|
342
|
+
|
|
343
|
+
const pages = await this.readPages();
|
|
344
|
+
if (pages.length === 0) {
|
|
345
|
+
throw new BrotherScanError('Brother scanner returned no document data', 'no-document');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const firstMetadata = readJpegMetadata(pages[0]);
|
|
349
|
+
return {
|
|
350
|
+
data: pages[0],
|
|
351
|
+
format: 'jpeg',
|
|
352
|
+
width: firstMetadata.width ?? area.width,
|
|
353
|
+
height: firstMetadata.height ?? area.height,
|
|
354
|
+
resolution: firstMetadata.resolution ?? lease.resolutionX,
|
|
355
|
+
colorMode: 'color',
|
|
356
|
+
mimeType: 'image/jpeg',
|
|
357
|
+
pageCount: pages.length,
|
|
358
|
+
pages: pages.map((data) => {
|
|
359
|
+
const metadata = readJpegMetadata(data);
|
|
360
|
+
return {
|
|
361
|
+
data,
|
|
362
|
+
width: metadata.width ?? area.width,
|
|
363
|
+
height: metadata.height ?? area.height,
|
|
364
|
+
mimeType: 'image/jpeg',
|
|
365
|
+
};
|
|
366
|
+
}),
|
|
367
|
+
isComplete: true,
|
|
368
|
+
settingsWarnings: [],
|
|
369
|
+
};
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (this.cancelled) {
|
|
372
|
+
throw new BrotherScanError('Brother scan was cancelled', 'cancelled');
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
await this.write(Buffer.from([0x1b, 0x52]));
|
|
376
|
+
} catch {
|
|
377
|
+
// Preserve the scan error if the connection is already gone.
|
|
378
|
+
}
|
|
379
|
+
throw error;
|
|
380
|
+
} finally {
|
|
381
|
+
this.scanning = false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
public async cancel(): Promise<void> {
|
|
386
|
+
if (!this.socket || !this.scanning) return;
|
|
387
|
+
this.cancelled = true;
|
|
388
|
+
await this.write(Buffer.from([0x1b, 0x52]));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private validateOptions(options: IBrotherScanOptions): void {
|
|
392
|
+
if (options.format !== 'jpeg') {
|
|
393
|
+
throw new BrotherScanError(
|
|
394
|
+
`Brother native scanning returns JPEG pages, not ${options.format}`,
|
|
395
|
+
'unsupported'
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
if (options.colorMode !== 'color') {
|
|
399
|
+
throw new BrotherScanError(
|
|
400
|
+
`Brother native ${options.colorMode} decoding is not implemented`,
|
|
401
|
+
'unsupported'
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private async selectSource(source: TScanSource): Promise<void> {
|
|
407
|
+
const brotherSource = source === 'flatbed' ? 'FB' : 'AUTO';
|
|
408
|
+
await this.writeCommand(['\x1bS', brotherSource]);
|
|
409
|
+
const response = await this.requireReader().readExactly(1, this.timeout);
|
|
410
|
+
if (response[0] !== TERMINATOR) {
|
|
411
|
+
throw this.statusError(response[0], 'Brother scanner rejected the requested source');
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private async requestLease(resolution: number): Promise<IBrotherScanLease> {
|
|
416
|
+
await this.writeCommand(['\x1bI', `R=${resolution},${resolution}`, 'M=CGRAY']);
|
|
417
|
+
const match = await this.requireReader().readMatch(
|
|
418
|
+
/(\d+),(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)/,
|
|
419
|
+
this.timeout,
|
|
420
|
+
256
|
|
421
|
+
);
|
|
422
|
+
this.requireReader().discard();
|
|
423
|
+
const values = match.slice(1).map((value) => Number.parseInt(value, 10));
|
|
424
|
+
return {
|
|
425
|
+
resolutionX: values[0],
|
|
426
|
+
resolutionY: values[1],
|
|
427
|
+
adfStatus: values[2],
|
|
428
|
+
planeWidth: values[3],
|
|
429
|
+
width: values[4],
|
|
430
|
+
planeHeight: values[5],
|
|
431
|
+
height: values[6],
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private assertLease(lease: IBrotherScanLease, options: IBrotherScanOptions): void {
|
|
436
|
+
if (lease.resolutionX !== options.resolution || lease.resolutionY !== options.resolution) {
|
|
437
|
+
throw new BrotherScanError(
|
|
438
|
+
`Brother scanner rejected ${options.resolution} dpi and offered ${lease.resolutionX}x${lease.resolutionY} dpi`,
|
|
439
|
+
'settings-rejected'
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
if (lease.width <= 0 || lease.height < 0) {
|
|
443
|
+
throw new BrotherScanError('Brother scanner returned an invalid scan area', 'invalid-response');
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private resolveScanArea(area: IScanArea, lease: IBrotherScanLease): IScanArea {
|
|
448
|
+
const mmToPixels = (millimetres: number, dpi: number) => Math.round((millimetres * dpi) / 25.4);
|
|
449
|
+
const x = Math.max(0, mmToPixels(area.x, lease.resolutionX));
|
|
450
|
+
const y = Math.max(0, mmToPixels(area.y, lease.resolutionY));
|
|
451
|
+
const requestedWidth = mmToPixels(area.width, lease.resolutionX);
|
|
452
|
+
const requestedHeight = mmToPixels(area.height, lease.resolutionY);
|
|
453
|
+
const width = Math.max(1, Math.min(requestedWidth, lease.width - x));
|
|
454
|
+
// Brother ADF leases report zero height because page length is determined
|
|
455
|
+
// while feeding. Platen leases provide a fixed pixel height.
|
|
456
|
+
const height = lease.height === 0
|
|
457
|
+
? Math.max(1, requestedHeight)
|
|
458
|
+
: Math.max(1, Math.min(requestedHeight, lease.height - y));
|
|
459
|
+
if (x >= lease.width || (lease.height > 0 && y >= lease.height)) {
|
|
460
|
+
throw new BrotherScanError('Requested scan area is outside the scanner bed', 'settings-rejected');
|
|
461
|
+
}
|
|
462
|
+
return { x, y, width, height };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private async readPages(): Promise<Buffer[]> {
|
|
466
|
+
const pages: Buffer[] = [];
|
|
467
|
+
let chunks: Buffer[] = [];
|
|
468
|
+
let pageBytes = 0;
|
|
469
|
+
const wrappedChunks = new Map<number, Buffer[]>();
|
|
470
|
+
const wrappedPageBytes = new Map<number, number>();
|
|
471
|
+
const wrappedPages = new Map<number, Buffer>();
|
|
472
|
+
let frameMode: TFrameMode | undefined;
|
|
473
|
+
|
|
474
|
+
while (pages.length + wrappedPages.size < MAX_PAGES) {
|
|
475
|
+
const header = (await this.requireReader().readExactly(1, this.scanTimeout))[0];
|
|
476
|
+
|
|
477
|
+
if (header < 0x80) {
|
|
478
|
+
const lengthBytes = await this.requireReader().readExactly(2, this.scanTimeout);
|
|
479
|
+
const firstLength = lengthBytes.readUInt16LE(0);
|
|
480
|
+
if (!frameMode) {
|
|
481
|
+
const startsWithJpeg = firstLength >= 2 &&
|
|
482
|
+
(await this.requireReader().peekExactly(2, this.scanTimeout)).equals(Buffer.from([0xff, 0xd8]));
|
|
483
|
+
frameMode = firstLength <= 64 && !startsWithJpeg ? 'wrapped' : 'simple';
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (frameMode === 'wrapped') {
|
|
487
|
+
const metadata = await this.requireReader().readExactly(firstLength, this.scanTimeout);
|
|
488
|
+
const dataLengthBytes = await this.requireReader().readExactly(2, this.scanTimeout);
|
|
489
|
+
const dataLength = dataLengthBytes.readUInt16LE(0);
|
|
490
|
+
const imageNumber = metadata.length >= 2 ? metadata.readUInt16LE(0) : 0;
|
|
491
|
+
const imageChunks = wrappedChunks.get(imageNumber) ?? [];
|
|
492
|
+
imageChunks.push(await this.requireReader().readExactly(dataLength, this.scanTimeout));
|
|
493
|
+
wrappedChunks.set(imageNumber, imageChunks);
|
|
494
|
+
const imageBytes = (wrappedPageBytes.get(imageNumber) ?? 0) + dataLength;
|
|
495
|
+
wrappedPageBytes.set(imageNumber, imageBytes);
|
|
496
|
+
if (imageBytes > MAX_PAGE_BYTES) {
|
|
497
|
+
throw new BrotherScanError('Brother scanner page exceeded the safety limit', 'invalid-response');
|
|
498
|
+
}
|
|
499
|
+
} else {
|
|
500
|
+
chunks.push(await this.requireReader().readExactly(firstLength, this.scanTimeout));
|
|
501
|
+
pageBytes += firstLength;
|
|
502
|
+
if (pageBytes > MAX_PAGE_BYTES) {
|
|
503
|
+
throw new BrotherScanError('Brother scanner page exceeded the safety limit', 'invalid-response');
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (header === 0x84 || header === 0x85) {
|
|
510
|
+
await this.requireReader().readExactly(1, this.scanTimeout);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (header === 0x81) {
|
|
515
|
+
this.finishPage(chunks, pages);
|
|
516
|
+
chunks = [];
|
|
517
|
+
pageBytes = 0;
|
|
518
|
+
frameMode = undefined;
|
|
519
|
+
await this.writeCommand(['\x1bX']);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (header === 0x82) {
|
|
524
|
+
const metadata = await this.requireReader().readExactly(9, this.scanTimeout);
|
|
525
|
+
const imageNumber = metadata.readUInt16LE(2);
|
|
526
|
+
let imageChunks = wrappedChunks.get(imageNumber);
|
|
527
|
+
let resolvedImageNumber = imageNumber;
|
|
528
|
+
if (!imageChunks && wrappedChunks.size === 1) {
|
|
529
|
+
const onlyImage = wrappedChunks.entries().next().value as [number, Buffer[]];
|
|
530
|
+
resolvedImageNumber = onlyImage[0];
|
|
531
|
+
imageChunks = onlyImage[1];
|
|
532
|
+
}
|
|
533
|
+
const page = this.makePage(imageChunks ?? []);
|
|
534
|
+
if (page) wrappedPages.set(resolvedImageNumber, page);
|
|
535
|
+
wrappedChunks.delete(resolvedImageNumber);
|
|
536
|
+
wrappedPageBytes.delete(resolvedImageNumber);
|
|
537
|
+
frameMode = undefined;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (header === TERMINATOR) {
|
|
542
|
+
this.finishPage(chunks, pages);
|
|
543
|
+
for (const [imageNumber, imageChunks] of wrappedChunks) {
|
|
544
|
+
const page = this.makePage(imageChunks);
|
|
545
|
+
if (page) wrappedPages.set(imageNumber, page);
|
|
546
|
+
}
|
|
547
|
+
pages.push(...[...wrappedPages.entries()]
|
|
548
|
+
.sort(([left], [right]) => left - right)
|
|
549
|
+
.map(([, page]) => page));
|
|
550
|
+
return pages;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
throw this.statusError(header, 'Brother scanner stopped the scan');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
throw new BrotherScanError(`Brother scanner exceeded the ${MAX_PAGES}-page safety limit`, 'invalid-response');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private finishPage(chunks: Buffer[], pages: Buffer[]): void {
|
|
560
|
+
const page = this.makePage(chunks);
|
|
561
|
+
if (page) pages.push(page);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private makePage(chunks: Buffer[]): Buffer | undefined {
|
|
565
|
+
if (chunks.length === 0) return undefined;
|
|
566
|
+
const page = Buffer.concat(chunks);
|
|
567
|
+
if (page[0] !== 0xff || page[1] !== 0xd8) {
|
|
568
|
+
const firstBytes = page.subarray(0, 8).toString('hex');
|
|
569
|
+
const lastBytes = page.subarray(Math.max(0, page.length - 8)).toString('hex');
|
|
570
|
+
throw new BrotherScanError(
|
|
571
|
+
`Brother scanner returned an invalid JPEG page (${page.length} bytes, first ${firstBytes}, last ${lastBytes})`,
|
|
572
|
+
'invalid-response'
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const endMarker = page.lastIndexOf(Buffer.from([0xff, 0xd9]));
|
|
577
|
+
return endMarker >= 0
|
|
578
|
+
? page.subarray(0, endMarker + 2)
|
|
579
|
+
: Buffer.concat([page, Buffer.from([0xff, 0xd9])]);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private statusError(status: number, prefix: string): BrotherScanError {
|
|
583
|
+
if (status === 0xc2) return new BrotherScanError(`${prefix}: no document in feeder`, 'no-document', status);
|
|
584
|
+
if (status === 0xc3) return new BrotherScanError(`${prefix}: document jam`, 'document-jam', status);
|
|
585
|
+
if (status === 0xc4) return new BrotherScanError(`${prefix}: cover open`, 'cover-open', status);
|
|
586
|
+
if (status === 0xc5) return new BrotherScanError(`${prefix}: service error`, 'service-error', status);
|
|
587
|
+
if (status === 0xc6) {
|
|
588
|
+
return new BrotherScanError(`${prefix}: device is stuck and must be restarted`, 'device-stuck', status);
|
|
589
|
+
}
|
|
590
|
+
if (status === 0x83 || status === 0x86 || status === 0xe3) {
|
|
591
|
+
return new BrotherScanError(`${prefix}: cancelled`, 'cancelled', status);
|
|
592
|
+
}
|
|
593
|
+
return new BrotherScanError(`${prefix}: status 0x${status.toString(16)}`, 'invalid-response', status);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private async writeCommand(lines: string[]): Promise<void> {
|
|
597
|
+
const command = `${lines.join('\n')}\n`;
|
|
598
|
+
await this.write(Buffer.concat([Buffer.from(command, 'ascii'), Buffer.from([TERMINATOR])]));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private async write(data: Buffer): Promise<void> {
|
|
602
|
+
const socket = this.socket;
|
|
603
|
+
if (!socket || socket.destroyed) {
|
|
604
|
+
throw new BrotherScanError('Brother scanner is not connected', 'invalid-response');
|
|
605
|
+
}
|
|
606
|
+
await new Promise<void>((resolve, reject) => {
|
|
607
|
+
socket.write(data, (error) => error ? reject(error) : resolve());
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private requireReader(): SocketBuffer {
|
|
612
|
+
if (!this.reader) throw new BrotherScanError('Brother scanner is not connected', 'invalid-response');
|
|
613
|
+
return this.reader;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function delay(milliseconds: number): Promise<void> {
|
|
618
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function readJpegMetadata(data: Buffer): { width?: number; height?: number; resolution?: number } {
|
|
622
|
+
if (data.length < 4 || data[0] !== 0xff || data[1] !== 0xd8) return {};
|
|
623
|
+
const metadata: { width?: number; height?: number; resolution?: number } = {};
|
|
624
|
+
const startOfFrameMarkers = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
|
|
625
|
+
let offset = 2;
|
|
626
|
+
while (offset + 4 <= data.length) {
|
|
627
|
+
while (offset < data.length && data[offset] === 0xff) offset++;
|
|
628
|
+
const marker = data[offset++];
|
|
629
|
+
if (marker === 0xd9 || marker === 0xda) break;
|
|
630
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
|
631
|
+
if (offset + 2 > data.length) break;
|
|
632
|
+
const length = data.readUInt16BE(offset);
|
|
633
|
+
if (length < 2 || offset + length > data.length) break;
|
|
634
|
+
const content = offset + 2;
|
|
635
|
+
if (marker === 0xe0 && length >= 16 && data.toString('ascii', content, content + 5) === 'JFIF\0') {
|
|
636
|
+
const units = data[content + 7];
|
|
637
|
+
const density = data.readUInt16BE(content + 8);
|
|
638
|
+
if (units === 1 && density > 0) metadata.resolution = density;
|
|
639
|
+
if (units === 2 && density > 0) metadata.resolution = Math.round(density * 2.54);
|
|
640
|
+
}
|
|
641
|
+
if (startOfFrameMarkers.has(marker) && length >= 7) {
|
|
642
|
+
metadata.height = data.readUInt16BE(content + 1);
|
|
643
|
+
metadata.width = data.readUInt16BE(content + 3);
|
|
644
|
+
}
|
|
645
|
+
offset += length;
|
|
646
|
+
}
|
|
647
|
+
return metadata;
|
|
648
|
+
}
|