@ecobridge.xyz/devicemanager 3.1.1 β†’ 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 (66) hide show
  1. package/changelog.md +25 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/device/device.classes.device.d.ts +2 -2
  4. package/dist_ts/device/device.classes.device.js +3 -3
  5. package/dist_ts/devicemanager.classes.devicemanager.d.ts +17 -4
  6. package/dist_ts/devicemanager.classes.devicemanager.js +67 -5
  7. package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +4 -0
  8. package/dist_ts/discovery/discovery.classes.networkscanner.js +43 -2
  9. package/dist_ts/events/events.observable.d.ts +15 -0
  10. package/dist_ts/events/events.observable.js +22 -0
  11. package/dist_ts/events/index.d.ts +1 -0
  12. package/dist_ts/events/index.js +2 -0
  13. package/dist_ts/factories/index.js +7 -5
  14. package/dist_ts/features/feature.abstract.d.ts +2 -2
  15. package/dist_ts/features/feature.abstract.js +3 -3
  16. package/dist_ts/features/feature.print.d.ts +3 -0
  17. package/dist_ts/features/feature.print.js +17 -2
  18. package/dist_ts/features/feature.scan.d.ts +5 -2
  19. package/dist_ts/features/feature.scan.js +98 -27
  20. package/dist_ts/index.d.ts +3 -1
  21. package/dist_ts/index.js +4 -2
  22. package/dist_ts/interfaces/feature.interfaces.d.ts +21 -1
  23. package/dist_ts/interfaces/index.d.ts +21 -3
  24. package/dist_ts/interfaces/index.js +1 -1
  25. package/dist_ts/plugins.d.ts +2 -1
  26. package/dist_ts/plugins.js +3 -2
  27. package/dist_ts/protocols/index.d.ts +2 -1
  28. package/dist_ts/protocols/index.js +4 -2
  29. package/dist_ts/protocols/protocol.brother.d.ts +67 -0
  30. package/dist_ts/protocols/protocol.brother.js +560 -0
  31. package/dist_ts/protocols/protocol.escl.d.ts +13 -4
  32. package/dist_ts/protocols/protocol.escl.js +344 -177
  33. package/dist_ts/protocols/protocol.ipp.d.ts +2 -0
  34. package/dist_ts/protocols/protocol.ipp.js +22 -2
  35. package/dist_ts/providers/index.d.ts +1 -0
  36. package/dist_ts/providers/index.js +2 -0
  37. package/dist_ts/providers/provider.smb.d.ts +60 -0
  38. package/dist_ts/providers/provider.smb.js +224 -0
  39. package/package.json +11 -7
  40. package/pnpm-workspace.yaml +2 -0
  41. package/readme.md +98 -4
  42. package/test/test.brother.node.ts +306 -0
  43. package/test/test.escl.node.ts +233 -0
  44. package/test/test.ipp.node.ts +75 -0
  45. package/test/test.node.ts +17 -0
  46. package/test/test.smb.node.ts +123 -0
  47. package/ts/00_commitinfo_data.ts +1 -1
  48. package/ts/device/device.classes.device.ts +2 -2
  49. package/ts/devicemanager.classes.devicemanager.ts +81 -6
  50. package/ts/discovery/discovery.classes.networkscanner.ts +50 -1
  51. package/ts/events/events.observable.ts +31 -0
  52. package/ts/events/index.ts +4 -0
  53. package/ts/factories/index.ts +7 -5
  54. package/ts/features/feature.abstract.ts +2 -2
  55. package/ts/features/feature.print.ts +16 -1
  56. package/ts/features/feature.scan.ts +93 -27
  57. package/ts/index.ts +20 -0
  58. package/ts/interfaces/feature.interfaces.ts +22 -1
  59. package/ts/interfaces/index.ts +22 -3
  60. package/ts/plugins.ts +2 -1
  61. package/ts/protocols/index.ts +11 -1
  62. package/ts/protocols/protocol.brother.ts +648 -0
  63. package/ts/protocols/protocol.escl.ts +347 -190
  64. package/ts/protocols/protocol.ipp.ts +24 -1
  65. package/ts/providers/index.ts +7 -0
  66. package/ts/providers/provider.smb.ts +326 -0
package/readme.md CHANGED
@@ -10,8 +10,9 @@ A comprehensive, TypeScript-first device manager for discovering and communicati
10
10
  `@ecobridge.xyz/devicemanager` provides a unified, object-oriented API for discovering and modeling network devices, with live communication where protocol implementations are available. Whether you're building a document scanning workflow, managing printers, controlling smart home devices, or monitoring UPS systems β€” this library has you covered.
11
11
 
12
12
  **Supported Device Types:**
13
- - πŸ–¨οΈ **Scanners** β€” eSCL (AirScan), SANE protocols
14
- - πŸ“„ **Printers** β€” IPP discovery and print execution; JetDirect/raw port detection
13
+ - πŸ–¨οΈ **Scanners** β€” Brother native network scanning, eSCL (AirScan), and SANE
14
+ - πŸ“„ **Printers** β€” IPP/AirPrint discovery, capabilities, jobs, cancellation, and print execution; JetDirect/raw port detection
15
+ - πŸ“ **SMB Shares** β€” Host local folders through smartsamba and emit settled document arrivals through RxJS
15
16
  - πŸ”Š **Speakers** β€” Sonos, AirPlay, Chromecast, and DLNA discovery with normalized feature/factory modeling
16
17
  - πŸ”‹ **UPS Systems** β€” NUT, SNMP protocols
17
18
  - πŸ“‘ **SNMP Devices** β€” Community-based SNMP v1 and v2c sessions; v3 is not implemented
@@ -107,7 +108,7 @@ The library follows a clean, composable architecture:
107
108
  β–Ό
108
109
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
109
110
  β”‚ Protocols β”‚
110
- β”‚ eSCL β”‚ SANE β”‚ IPP β”‚ SNMP β”‚ NUT β”‚ UPnP/SOAP β”‚ Home Assistantβ”‚
111
+ β”‚ Brother Scan β”‚ eSCL β”‚ SANE β”‚ IPP β”‚ SNMP β”‚ NUT β”‚ UPnP/SOAP β”‚
111
112
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
112
113
  ```
113
114
 
@@ -142,6 +143,7 @@ const printers = await manager.discoverPrinters('192.168.1.0/24');
142
143
  // General network scan (all device types)
143
144
  const devices = await manager.scanNetwork({
144
145
  ipRange: '192.168.1.0/24',
146
+ probeBrother: true,
145
147
  probeEscl: true,
146
148
  probeIpp: true,
147
149
  probeSane: true,
@@ -237,11 +239,38 @@ const result = await scanFeature.scan({
237
239
 
238
240
  // result.data is a Buffer containing the scanned image
239
241
  await fs.writeFile('scan.jpg', result.data);
242
+
243
+ // ADF scans expose every returned document. `data` remains the first document
244
+ // for backwards compatibility. Use `pages` to avoid dropping later sheets/sides.
245
+ console.log(`Scanner returned ${result.pageCount} document(s)`);
246
+ for (const [index, page] of (result.pages ?? []).entries()) {
247
+ await fs.writeFile(`scan-${index + 1}.jpg`, page.data);
248
+ }
240
249
  ```
241
250
 
251
+ If a scanner times out or cancels after transferring one or more documents,
252
+ `EsclScanError.partialResult` contains those documents with `isComplete: false`.
253
+ Callers can persist `partialResult.pages` before deciding whether to retry.
254
+ Some scanner firmware accepts eSCL settings but returns a different format or
255
+ resolution. Check `result.settingsWarnings` before treating the requested scan
256
+ quality as confirmed.
257
+
258
+ Brother devices discovered on TCP 54921 use the native scan transport before
259
+ eSCL. It explicitly selects `AUTO` for ADF input, controls simplex/duplex, and
260
+ rejects a lease when the device does not grant the requested resolution. The
261
+ native transport currently returns color JPEG pages; unsupported output formats
262
+ or color modes fail explicitly instead of being silently substituted.
263
+ Duplex-capable scanners default to `adf-duplex`; callers can still request
264
+ `adf` or `flatbed` explicitly. The transport preserves every returned side.
265
+ Blank-page classification and removal belong in consuming applications, where
266
+ document-specific review rules can be applied.
267
+
242
268
  #### πŸ“„ PrintFeature
243
269
 
244
- `PrintFeature.print()` submits jobs through IPP. Network discovery can identify an open JetDirect/raw port at TCP 9100, but JetDirect print execution is not implemented.
270
+ `PrintFeature.print()` submits jobs through IPP, the print transport used by
271
+ AirPrint. It reads printer capabilities, submits jobs, reports job state, and
272
+ supports cancellation. Network discovery can also identify an open
273
+ JetDirect/raw port at TCP 9100, but JetDirect print execution is not implemented.
245
274
 
246
275
  ```typescript
247
276
  const printFeature = device.selectFeature<PrintFeature>('print');
@@ -249,6 +278,7 @@ await printFeature.connect();
249
278
 
250
279
  // Get printer capabilities
251
280
  const caps = await printFeature.getCapabilities();
281
+ console.log(caps.airPrintSupported, caps.documentFormats, caps.resolutions);
252
282
 
253
283
  // Print a document
254
284
  const job = await printFeature.print(pdfBuffer, {
@@ -264,6 +294,60 @@ const job = await printFeature.print(pdfBuffer, {
264
294
  const jobInfo = await printFeature.getJobInfo(job.id);
265
295
  ```
266
296
 
297
+ #### πŸ“ SMB Share Provider
298
+
299
+ `DeviceManager.provideSmbShares()` hosts one or more local folders with
300
+ `@push.rocks/smartsamba`. The provider waits until a new file has stopped
301
+ changing before it emits `document:arrived`, which keeps consumers from opening
302
+ a scan while the sending device is still writing it.
303
+
304
+ ```typescript
305
+ import { DeviceManager } from '@ecobridge.xyz/devicemanager';
306
+
307
+ const manager = new DeviceManager({ autoDiscovery: false });
308
+ const provider = await manager.provideSmbShares({
309
+ host: '0.0.0.0',
310
+ port: 445,
311
+ users: [{
312
+ username: 'scanner',
313
+ password: process.env.SMB_SCANNER_PASSWORD!,
314
+ }],
315
+ shares: [{
316
+ name: 'scans',
317
+ path: '/srv/device-scans',
318
+ users: [{ username: 'scanner', access: 'readWrite' }],
319
+ }],
320
+ settleTimeMs: 1000,
321
+ });
322
+
323
+ const subscription = provider.documents$.subscribe((document) => {
324
+ console.log(`New document: ${document.shareName}/${document.relativePath}`);
325
+ });
326
+
327
+ // The same event is forwarded through the manager as smb:document:arrived.
328
+ const managerSubscription = manager.events$.subscribe((event) => {
329
+ if (event.name === 'smb:document:arrived') {
330
+ console.log(event.args[0]);
331
+ }
332
+ });
333
+
334
+ // Later:
335
+ subscription.unsubscribe();
336
+ managerSubscription.unsubscribe();
337
+ await manager.shutdown();
338
+ ```
339
+
340
+ The provider recursively monitors every configured share. It also emits
341
+ `document:changed`, `document:removed`, `started`, `stopped`, and
342
+ `provider:error`. Existing files are treated as the startup baseline unless
343
+ `emitExistingDocuments` is enabled.
344
+
345
+ Binding SMB's standard TCP port 445 can require elevated privileges. Published
346
+ smartsamba 0.3.1 packages support signed SMB 2.0.2 and 2.1 clients and include
347
+ Linux amd64 and arm64 engines. Other platforms can provide a locally built engine
348
+ through `SMARTSAMBA_RUST_BINARY`.
349
+ Do not store SMB passwords in device metadata or log provider options.
350
+
267
351
  #### πŸ”Š PlaybackFeature & VolumeFeature
268
352
 
269
353
  `PlaybackFeature` and `VolumeFeature` provide normalized capability and state models. No external Sonos or Chromecast protocol client is bundled or wired into these features, so their methods update local feature state and emit events but do not provide live playback control for those devices.
@@ -522,9 +606,17 @@ The smart-home factories require a `protocolClient` that implements the correspo
522
606
 
523
607
  ### Event Handling
524
608
 
609
+ `DeviceManager`, `UniversalDevice`, every `Feature`, and host-side providers
610
+ remain Node `EventEmitter` instances and additionally expose `events$`. Each
611
+ observable item contains the event `name`, its `args`, and `emittedAt`.
612
+
525
613
  ```typescript
526
614
  const manager = new DeviceManager();
527
615
 
616
+ const subscription = manager.events$.subscribe((event) => {
617
+ console.log(event.name, event.args, event.emittedAt);
618
+ });
619
+
528
620
  // Discovery events
529
621
  manager.on('device:found', ({ device, featureType }) => {
530
622
  console.log(`Found ${device.name} with ${featureType} capability`);
@@ -547,6 +639,8 @@ device.on('status:changed', ({ oldStatus, newStatus }) => {
547
639
  device.on('feature:connected', (featureType) => {
548
640
  console.log(`Feature ${featureType} connected`);
549
641
  });
642
+
643
+ subscription.unsubscribe();
550
644
  ```
551
645
 
552
646
  ### Error Handling
@@ -0,0 +1,306 @@
1
+ import * as net from 'node:net';
2
+
3
+ import { expect, tap } from '@git.zone/tstest/tapbundle';
4
+ import {
5
+ BrotherScanError,
6
+ BrotherScanProtocol,
7
+ } from '../ts/protocols/protocol.brother.js';
8
+
9
+ function makeJpeg(width: number, height: number, resolution: number): Buffer {
10
+ const padding = Buffer.alloc(80, 0x41);
11
+ return Buffer.concat([
12
+ Buffer.from([
13
+ 0xff, 0xd8,
14
+ 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x01,
15
+ (resolution >> 8) & 0xff, resolution & 0xff,
16
+ (resolution >> 8) & 0xff, resolution & 0xff, 0x00, 0x00,
17
+ 0xff, 0xfe, 0x00, 0x52,
18
+ ]),
19
+ padding,
20
+ Buffer.from([
21
+ 0xff, 0xc0, 0x00, 0x0b, 0x08,
22
+ (height >> 8) & 0xff, height & 0xff,
23
+ (width >> 8) & 0xff, width & 0xff,
24
+ 0x01, 0x01, 0x11, 0x00,
25
+ 0xff, 0xd9,
26
+ ]),
27
+ ]);
28
+ }
29
+
30
+ function simpleFrame(payload: Buffer): Buffer {
31
+ const length = Buffer.alloc(2);
32
+ length.writeUInt16LE(payload.length);
33
+ return Buffer.concat([Buffer.from([0x40]), length, payload]);
34
+ }
35
+
36
+ function wrappedFrame(payload: Buffer, imageNumber: number = 1): Buffer {
37
+ const header = Buffer.alloc(12, 0);
38
+ header[0] = 0x40;
39
+ header.writeUInt16LE(7, 1);
40
+ header.writeUInt16LE(imageNumber, 3);
41
+ header.writeUInt16LE(payload.length, 10);
42
+ return Buffer.concat([header, payload]);
43
+ }
44
+
45
+ function endOfImage(imageNumber: number): Buffer {
46
+ const header = Buffer.alloc(10, 0);
47
+ header[0] = 0x82;
48
+ header.writeUInt16LE(7, 1);
49
+ header.writeUInt16LE(imageNumber, 3);
50
+ return header;
51
+ }
52
+
53
+ function readCommands(socket: net.Socket, onCommand: (command: Buffer) => void): void {
54
+ let input = Buffer.alloc(0);
55
+ socket.on('data', (data) => {
56
+ input = Buffer.concat([input, data]);
57
+ while (true) {
58
+ const end = input.indexOf(0x80);
59
+ if (end < 0) return;
60
+ const command = input.subarray(0, end + 1);
61
+ input = input.subarray(end + 1);
62
+ onCommand(command);
63
+ }
64
+ });
65
+ }
66
+
67
+ tap.test('Brother native scan supports variable-length ADF leases and drains every page', async () => {
68
+ const commands: string[] = [];
69
+ const firstPage = makeJpeg(5052, 8400, 600);
70
+ const secondPage = makeJpeg(5052, 8400, 600);
71
+ const secondPageWithoutEndMarker = secondPage.subarray(0, -2);
72
+ const server = net.createServer((socket) => {
73
+ socket.write('+OK 200\r\n');
74
+ readCommands(socket, (command) => {
75
+ const text = command.toString('latin1');
76
+ commands.push(text);
77
+ if (text.startsWith('\x1bS\n')) {
78
+ socket.write(Buffer.from([0x80]));
79
+ } else if (text.startsWith('\x1bI\n')) {
80
+ socket.write(Buffer.concat([
81
+ Buffer.from([0x1a, 0x00]),
82
+ Buffer.from('600,600,1,213,5052,0,0', 'ascii'),
83
+ ]));
84
+ } else if (text.includes('D=DUP')) {
85
+ socket.write(Buffer.concat([
86
+ wrappedFrame(firstPage.subarray(0, Math.floor(firstPage.length / 2)), 1),
87
+ wrappedFrame(secondPageWithoutEndMarker.subarray(0, Math.floor(secondPageWithoutEndMarker.length / 2)), 2),
88
+ wrappedFrame(firstPage.subarray(Math.floor(firstPage.length / 2)), 1),
89
+ endOfImage(1),
90
+ wrappedFrame(secondPageWithoutEndMarker.subarray(Math.floor(secondPageWithoutEndMarker.length / 2)), 2),
91
+ endOfImage(2),
92
+ Buffer.from([0x80]),
93
+ ]));
94
+ }
95
+ });
96
+ });
97
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
98
+ const address = server.address() as net.AddressInfo;
99
+ const client = new BrotherScanProtocol('127.0.0.1', address.port, { timeout: 1000, scanTimeout: 1000 });
100
+
101
+ try {
102
+ await client.connect();
103
+ const result = await client.scan({
104
+ source: 'adf-duplex',
105
+ resolution: 600,
106
+ format: 'jpeg',
107
+ colorMode: 'color',
108
+ quality: 90,
109
+ area: { x: 0, y: 0, width: 215.9, height: 355.6 },
110
+ });
111
+
112
+ expect(result.pageCount).toEqual(2);
113
+ expect(result.pages?.[0]?.data.equals(firstPage)).toEqual(true);
114
+ expect(result.pages?.[1]?.data.equals(secondPage)).toEqual(true);
115
+ expect(result.width).toEqual(5052);
116
+ expect(result.height).toEqual(8400);
117
+ expect(result.resolution).toEqual(600);
118
+ expect(commands[0]).toEqual('\x1bS\nAUTO\n\x80');
119
+ expect(commands[1]).toContain('R=600,600');
120
+ expect(commands[2]).toContain('D=DUP');
121
+ expect(commands[2]).toContain('C=JPEG\nJ=MID');
122
+ expect(commands[2]).toContain('A=0,0,5052,8400');
123
+ expect(commands).toHaveLength(3);
124
+ } finally {
125
+ await client.disconnect();
126
+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
127
+ }
128
+ });
129
+
130
+ tap.test('Brother native scan uses FB and bottom-right scan coordinates', async () => {
131
+ const commands: string[] = [];
132
+ const page = makeJpeg(1181, 1417, 300);
133
+ const server = net.createServer((socket) => {
134
+ socket.write('+OK 200\r\n');
135
+ readCommands(socket, (command) => {
136
+ const text = command.toString('latin1');
137
+ commands.push(text);
138
+ if (text.startsWith('\x1bS\n')) {
139
+ socket.write(Buffer.from([0x80]));
140
+ } else if (text.startsWith('\x1bI\n')) {
141
+ socket.write(Buffer.concat([
142
+ Buffer.from([0x1a, 0x00]),
143
+ Buffer.from('300,300,0,216,2551,356,4205', 'ascii'),
144
+ ]));
145
+ } else if (text.startsWith('\x1bX\n')) {
146
+ socket.write(Buffer.concat([wrappedFrame(page), endOfImage(1), Buffer.from([0x80])]));
147
+ }
148
+ });
149
+ });
150
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
151
+ const address = server.address() as net.AddressInfo;
152
+ const client = new BrotherScanProtocol('127.0.0.1', address.port, {
153
+ timeout: 1000,
154
+ scanTimeout: 1000,
155
+ });
156
+
157
+ try {
158
+ await client.connect();
159
+ const result = await client.scan({
160
+ source: 'flatbed',
161
+ resolution: 300,
162
+ format: 'jpeg',
163
+ colorMode: 'color',
164
+ area: { x: 10, y: 20, width: 100, height: 120 },
165
+ });
166
+
167
+ expect(result.pageCount).toEqual(1);
168
+ expect(commands[0]).toEqual('\x1bS\nFB\n\x80');
169
+ expect(commands[2]).toContain('A=118,236,1299,1653');
170
+ expect(commands[2]).not.toContain('U=OFF');
171
+ expect(commands[2]).not.toContain('P=OFF');
172
+ } finally {
173
+ await client.disconnect();
174
+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
175
+ }
176
+ });
177
+
178
+ tap.test('Brother legacy page boundaries request the next page', async () => {
179
+ const commands: string[] = [];
180
+ const firstPage = makeJpeg(2480, 3507, 300);
181
+ const secondPage = makeJpeg(2480, 3507, 300);
182
+ const server = net.createServer((socket) => {
183
+ socket.write('+OK 200\r\n');
184
+ readCommands(socket, (command) => {
185
+ const text = command.toString('latin1');
186
+ commands.push(text);
187
+ if (text.startsWith('\x1bS\n')) {
188
+ socket.write(Buffer.from([0x80]));
189
+ } else if (text.startsWith('\x1bI\n')) {
190
+ socket.write(Buffer.concat([
191
+ Buffer.from([0x1a, 0x00]),
192
+ Buffer.from('300,300,2,210,2480,297,3507', 'ascii'),
193
+ ]));
194
+ } else if (text.includes('C=JPEG')) {
195
+ socket.write(Buffer.concat([simpleFrame(firstPage), Buffer.from([0x81])]));
196
+ } else if (text === '\x1bX\n\x80') {
197
+ socket.write(Buffer.concat([simpleFrame(secondPage), Buffer.from([0x80])]));
198
+ }
199
+ });
200
+ });
201
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
202
+ const address = server.address() as net.AddressInfo;
203
+ const client = new BrotherScanProtocol('127.0.0.1', address.port, {
204
+ timeout: 1000,
205
+ scanTimeout: 1000,
206
+ });
207
+
208
+ try {
209
+ await client.connect();
210
+ const result = await client.scan({
211
+ source: 'adf', resolution: 300, format: 'jpeg', colorMode: 'color',
212
+ area: { x: 0, y: 0, width: 210, height: 297 },
213
+ });
214
+ expect(result.pageCount).toEqual(2);
215
+ expect(commands[3]).toEqual('\x1bX\n\x80');
216
+ } finally {
217
+ await client.disconnect();
218
+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
219
+ }
220
+ });
221
+
222
+ tap.test('Brother native scan rejects a silently downgraded resolution', async () => {
223
+ const server = net.createServer((socket) => {
224
+ socket.write('+OK 200\r\n');
225
+ readCommands(socket, (command) => {
226
+ const text = command.toString('latin1');
227
+ if (text.startsWith('\x1bS\n')) socket.write(Buffer.from([0x80]));
228
+ if (text.startsWith('\x1bI\n')) {
229
+ socket.write(Buffer.concat([
230
+ Buffer.from([0x1a, 0x00]),
231
+ Buffer.from('300,300,2,210,2480,297,3507', 'ascii'),
232
+ ]));
233
+ }
234
+ });
235
+ });
236
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
237
+ const address = server.address() as net.AddressInfo;
238
+ const client = new BrotherScanProtocol('127.0.0.1', address.port, { timeout: 1000 });
239
+
240
+ try {
241
+ await client.connect();
242
+ let scanError: BrotherScanError | undefined;
243
+ try {
244
+ await client.scan({
245
+ source: 'adf', resolution: 600, format: 'jpeg', colorMode: 'color',
246
+ area: { x: 0, y: 0, width: 210, height: 297 },
247
+ });
248
+ } catch (error) {
249
+ if (error instanceof BrotherScanError) scanError = error;
250
+ }
251
+ expect(scanError?.code).toEqual('settings-rejected');
252
+ expect(scanError?.message).toContain('offered 300x300 dpi');
253
+ } finally {
254
+ await client.disconnect();
255
+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
256
+ }
257
+ });
258
+
259
+ tap.test('Brother native scan retries a temporarily busy device', async () => {
260
+ let connections = 0;
261
+ const server = net.createServer((socket) => {
262
+ connections++;
263
+ if (connections < 3) socket.end('-NG 401\r\n');
264
+ else socket.write('+OK 200\r\n');
265
+ });
266
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
267
+ const address = server.address() as net.AddressInfo;
268
+ const client = new BrotherScanProtocol('127.0.0.1', address.port, {
269
+ timeout: 1000,
270
+ busyRetryInterval: 5,
271
+ busyTimeout: 100,
272
+ });
273
+
274
+ try {
275
+ await client.connect();
276
+ expect(connections).toEqual(3);
277
+ } finally {
278
+ await client.disconnect();
279
+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
280
+ }
281
+ });
282
+
283
+ tap.test('Brother native scan reports a persistently occupied device as busy', async () => {
284
+ const server = net.createServer((socket) => socket.end('-NG 401\r\n'));
285
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
286
+ const address = server.address() as net.AddressInfo;
287
+ const client = new BrotherScanProtocol('127.0.0.1', address.port, {
288
+ timeout: 1000,
289
+ busyTimeout: 0,
290
+ });
291
+
292
+ try {
293
+ let connectError: BrotherScanError | undefined;
294
+ try {
295
+ await client.connect();
296
+ } catch (error) {
297
+ if (error instanceof BrotherScanError) connectError = error;
298
+ }
299
+ expect(connectError?.code).toEqual('busy');
300
+ } finally {
301
+ await client.disconnect();
302
+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
303
+ }
304
+ });
305
+
306
+ export default tap.start();