@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
@@ -0,0 +1,233 @@
1
+ import * as http from 'node:http';
2
+
3
+ import { expect, tap } from '@git.zone/tstest/tapbundle';
4
+ import { EsclProtocol, EsclScanError } from '../ts/protocols/protocol.escl.js';
5
+
6
+ const capabilitiesXml = `<?xml version="1.0" encoding="UTF-8"?>
7
+ <scan:ScannerCapabilities xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm">
8
+ <pwg:Version>2.62</pwg:Version>
9
+ <pwg:MakeAndModel>Brother MFC-J5730DW</pwg:MakeAndModel>
10
+ <pwg:SerialNumber>test-serial</pwg:SerialNumber>
11
+ <scan:Platen><scan:PlatenInputCaps>
12
+ <scan:MinWidth>16</scan:MinWidth><scan:MaxWidth>2550</scan:MaxWidth>
13
+ <scan:MinHeight>16</scan:MinHeight><scan:MaxHeight>3507</scan:MaxHeight>
14
+ <scan:MaxScanRegions>1</scan:MaxScanRegions>
15
+ <scan:SettingProfiles><scan:SettingProfile>
16
+ <scan:ColorModes><scan:ColorMode>RGB24</scan:ColorMode><scan:ColorMode>Grayscale8</scan:ColorMode></scan:ColorModes>
17
+ <scan:DocumentFormats><pwg:DocumentFormat>application/pdf</pwg:DocumentFormat><scan:DocumentFormatExt>image/jpeg</scan:DocumentFormatExt></scan:DocumentFormats>
18
+ <scan:SupportedResolutions><scan:DiscreteResolutions>
19
+ <scan:DiscreteResolution><scan:XResolution>300</scan:XResolution><scan:YResolution>300</scan:YResolution></scan:DiscreteResolution>
20
+ <scan:DiscreteResolution><scan:XResolution>600</scan:XResolution><scan:YResolution>600</scan:YResolution></scan:DiscreteResolution>
21
+ </scan:DiscreteResolutions></scan:SupportedResolutions>
22
+ </scan:SettingProfile></scan:SettingProfiles>
23
+ </scan:PlatenInputCaps></scan:Platen>
24
+ <scan:Adf>
25
+ <scan:AdfSimplexInputCaps>
26
+ <scan:MaxWidth>2550</scan:MaxWidth><scan:MaxHeight>4200</scan:MaxHeight>
27
+ <scan:SettingProfiles><scan:SettingProfile><scan:ColorModes><scan:ColorMode>RGB24</scan:ColorMode></scan:ColorModes><scan:DocumentFormats><pwg:DocumentFormat>application/pdf</pwg:DocumentFormat></scan:DocumentFormats><scan:SupportedResolutions><scan:DiscreteResolutions><scan:DiscreteResolution><scan:XResolution>600</scan:XResolution></scan:DiscreteResolution></scan:DiscreteResolutions></scan:SupportedResolutions></scan:SettingProfile></scan:SettingProfiles>
28
+ </scan:AdfSimplexInputCaps>
29
+ <scan:AdfDuplexInputCaps>
30
+ <scan:MaxWidth>2550</scan:MaxWidth><scan:MaxHeight>4200</scan:MaxHeight>
31
+ <scan:SettingProfiles><scan:SettingProfile><scan:ColorModes><scan:ColorMode>BlackAndWhite1</scan:ColorMode></scan:ColorModes><scan:DocumentFormats><pwg:DocumentFormat>application/pdf</pwg:DocumentFormat></scan:DocumentFormats><scan:SupportedResolutions><scan:DiscreteResolutions><scan:DiscreteResolution><scan:XResolution>600</scan:XResolution></scan:DiscreteResolution></scan:DiscreteResolutions></scan:SupportedResolutions></scan:SettingProfile></scan:SettingProfiles>
32
+ </scan:AdfDuplexInputCaps>
33
+ </scan:Adf>
34
+ </scan:ScannerCapabilities>`;
35
+
36
+ const statusXml = `<?xml version="1.0" encoding="UTF-8"?>
37
+ <scan:ScannerStatus xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm">
38
+ <pwg:State>Idle</pwg:State><scan:AdfState>ScannerAdfLoaded</scan:AdfState>
39
+ <scan:Jobs><scan:JobInfo>
40
+ <pwg:JobUri>/eSCL/ScanJobs/test-job</pwg:JobUri><pwg:JobUuid>urn:uuid:test-job</pwg:JobUuid>
41
+ <scan:Age>3</scan:Age><pwg:ImagesCompleted>2</pwg:ImagesCompleted><pwg:ImagesToTransfer>0</pwg:ImagesToTransfer>
42
+ <pwg:JobState>Completed</pwg:JobState><pwg:JobStateReasons><pwg:JobStateReason>JobCompletedSuccessfully</pwg:JobStateReason></pwg:JobStateReasons>
43
+ </scan:JobInfo></scan:Jobs>
44
+ </scan:ScannerStatus>`;
45
+
46
+ tap.test('eSCL parses namespaced Brother capabilities and status', async () => {
47
+ const server = http.createServer((request, response) => {
48
+ if (request.url === '/eSCL/ScannerCapabilities') {
49
+ response.writeHead(200, { 'content-type': 'text/xml' });
50
+ response.end(capabilitiesXml);
51
+ return;
52
+ }
53
+ if (request.url === '/eSCL/ScannerStatus') {
54
+ response.writeHead(200, { 'content-type': 'text/xml' });
55
+ response.end(statusXml);
56
+ return;
57
+ }
58
+ response.writeHead(404).end();
59
+ });
60
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
61
+ const address = server.address() as import('node:net').AddressInfo;
62
+ try {
63
+ const client = new EsclProtocol('127.0.0.1', address.port);
64
+ const capabilities = await client.getCapabilities();
65
+ const status = await client.getStatus();
66
+
67
+ expect(capabilities.version).toEqual('2.62');
68
+ expect(capabilities.makeAndModel).toEqual('Brother MFC-J5730DW');
69
+ expect(capabilities.platen?.supportedResolutions).toEqual([300, 600]);
70
+ expect(capabilities.platen?.colorModes).toEqual(['RGB24', 'Grayscale8']);
71
+ expect(capabilities.platen?.documentFormats).toEqual(['application/pdf', 'image/jpeg']);
72
+ expect(Math.round((capabilities.platen?.maxWidth ?? 0) * 10) / 10).toEqual(215.9);
73
+ expect(capabilities.adfDuplex?.colorModes).toEqual(['BlackAndWhite1']);
74
+ expect(status.adfState).toEqual('Loaded');
75
+ expect(status.jobs?.[0]?.jobStateReason).toEqual('JobCompletedSuccessfully');
76
+ } finally {
77
+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
78
+ }
79
+ });
80
+
81
+ tap.test('eSCL resolves relative jobs and drains every feeder document', async () => {
82
+ const pageOne = Buffer.from('%PDF-1.4\npage one');
83
+ const pageTwo = Buffer.from('%PDF-1.4\npage two');
84
+ const documents = [pageOne, pageTwo];
85
+ let settings = '';
86
+ let contentType = '';
87
+ let nextDocumentCalls = 0;
88
+ const server = http.createServer((request, response) => {
89
+ if (request.method === 'POST' && request.url === '/eSCL/ScanJobs') {
90
+ contentType = request.headers['content-type'] ?? '';
91
+ request.setEncoding('utf8');
92
+ request.on('data', (chunk) => { settings += chunk; });
93
+ request.on('end', () => {
94
+ response.writeHead(201, { location: '/eSCL/ScanJobs/test-job' });
95
+ response.end();
96
+ });
97
+ return;
98
+ }
99
+ if (request.url === '/eSCL/ScanJobs/test-job/NextDocument') {
100
+ const document = documents[nextDocumentCalls++];
101
+ if (!document) {
102
+ response.writeHead(404).end();
103
+ return;
104
+ }
105
+ response.writeHead(200, {
106
+ 'content-type': 'application/pdf',
107
+ 'x-image-width': '4960',
108
+ 'x-image-height': '7014',
109
+ });
110
+ response.end(document);
111
+ return;
112
+ }
113
+ if (request.url === '/eSCL/ScannerStatus') {
114
+ response.writeHead(200, { 'content-type': 'text/xml' });
115
+ response.end(statusXml);
116
+ return;
117
+ }
118
+ if (request.method === 'DELETE' && request.url === '/eSCL/ScanJobs/test-job') {
119
+ response.writeHead(204).end();
120
+ return;
121
+ }
122
+ response.writeHead(404).end();
123
+ });
124
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
125
+ const address = server.address() as import('node:net').AddressInfo;
126
+ try {
127
+ const client = new EsclProtocol('127.0.0.1', address.port);
128
+ const result = await client.scan({
129
+ source: 'adf-duplex', format: 'pdf', resolution: 600, colorMode: 'color',
130
+ area: { x: 0, y: 0, width: 210, height: 297 }, intent: 'document',
131
+ });
132
+
133
+ expect(result.data.equals(pageOne)).toEqual(true);
134
+ expect(result.pageCount).toEqual(2);
135
+ expect(result.pages?.map((page) => page.data.toString())).toEqual([
136
+ pageOne.toString(), pageTwo.toString(),
137
+ ]);
138
+ expect(nextDocumentCalls).toEqual(3);
139
+ expect(settings).toContain('<pwg:InputSource>Feeder</pwg:InputSource>');
140
+ expect(settings).toContain('<scan:Duplex>true</scan:Duplex>');
141
+ expect(settings).toContain('<scan:Intent>Document</scan:Intent>');
142
+ expect(settings).toContain('<pwg:DocumentFormat>application/pdf</pwg:DocumentFormat>');
143
+ expect(settings).toContain('<pwg:Height>3508</pwg:Height>');
144
+ expect(contentType).toEqual('text/xml');
145
+ } finally {
146
+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
147
+ }
148
+ });
149
+
150
+ tap.test('eSCL preserves transferred documents when the scanner times out', async () => {
151
+ const page = Buffer.from('%PDF-1.4\npartial page');
152
+ let nextDocumentCalls = 0;
153
+ const timedOutStatus = statusXml
154
+ .replace('<pwg:JobState>Completed</pwg:JobState>', '<pwg:JobState>Canceled</pwg:JobState>')
155
+ .replace('JobCompletedSuccessfully', 'JobTimedOut');
156
+ const server = http.createServer((request, response) => {
157
+ if (request.method === 'POST' && request.url === '/eSCL/ScanJobs') {
158
+ response.writeHead(201, { location: '/eSCL/ScanJobs/test-job' }).end();
159
+ return;
160
+ }
161
+ if (request.url === '/eSCL/ScanJobs/test-job/NextDocument') {
162
+ if (nextDocumentCalls++ === 0) {
163
+ response.writeHead(200, { 'content-type': 'application/pdf' }).end(page);
164
+ } else {
165
+ response.writeHead(404).end();
166
+ }
167
+ return;
168
+ }
169
+ if (request.url === '/eSCL/ScannerStatus') {
170
+ response.writeHead(200, { 'content-type': 'text/xml' }).end(timedOutStatus);
171
+ return;
172
+ }
173
+ if (request.method === 'DELETE') {
174
+ response.writeHead(204).end();
175
+ return;
176
+ }
177
+ response.writeHead(404).end();
178
+ });
179
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
180
+ const address = server.address() as import('node:net').AddressInfo;
181
+ try {
182
+ const client = new EsclProtocol('127.0.0.1', address.port);
183
+ let scanError: EsclScanError | undefined;
184
+ try {
185
+ await client.scan({ source: 'adf-duplex', format: 'pdf' });
186
+ } catch (error) {
187
+ if (error instanceof EsclScanError) scanError = error;
188
+ }
189
+ expect(scanError?.message).toContain('JobTimedOut');
190
+ expect(scanError?.partialResult?.pageCount).toEqual(1);
191
+ expect(scanError?.partialResult?.pages?.[0]?.data.equals(page)).toEqual(true);
192
+ expect(scanError?.partialResult?.isComplete).toEqual(false);
193
+ } finally {
194
+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
195
+ }
196
+ });
197
+
198
+ tap.test('eSCL reports the response format and JPEG metadata actually returned', async () => {
199
+ const jpeg = Buffer.from([
200
+ 0xff, 0xd8,
201
+ 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x01, 0x01, 0x2c, 0x01, 0x2c, 0x00, 0x00,
202
+ 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x08, 0x91, 0x06, 0x90, 0x01, 0x01, 0x11, 0x00,
203
+ 0xff, 0xd9,
204
+ ]);
205
+ const server = http.createServer((request, response) => {
206
+ if (request.url === '/eSCL/ScanJobs/jpeg-job/NextDocument') {
207
+ response.writeHead(200, { 'content-type': 'image/jpeg' }).end(jpeg);
208
+ return;
209
+ }
210
+ response.writeHead(404).end();
211
+ });
212
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
213
+ const address = server.address() as import('node:net').AddressInfo;
214
+ try {
215
+ const client = new EsclProtocol('127.0.0.1', address.port);
216
+ const result = await client.downloadScan('/eSCL/ScanJobs/jpeg-job', {
217
+ format: 'pdf', resolution: 600, colorMode: 'color',
218
+ });
219
+ expect(result.format).toEqual('jpeg');
220
+ expect(result.mimeType).toEqual('image/jpeg');
221
+ expect(result.width).toEqual(1680);
222
+ expect(result.height).toEqual(2193);
223
+ expect(result.resolution).toEqual(300);
224
+ expect(result.settingsWarnings).toEqual([
225
+ 'Requested pdf output but scanner returned jpeg',
226
+ 'Requested 600 dpi but scanner returned 300 dpi',
227
+ ]);
228
+ } finally {
229
+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
230
+ }
231
+ });
232
+
233
+ export default tap.start();
@@ -0,0 +1,75 @@
1
+ import * as http from 'node:http';
2
+
3
+ import { expect, tap } from '@git.zone/tstest/tapbundle';
4
+ import { IppProtocol } from '../ts/protocols/protocol.ipp.js';
5
+
6
+ function ippAttribute(tag: number, name: string, value: Buffer): Buffer {
7
+ const nameBuffer = Buffer.from(name, 'utf-8');
8
+ const header = Buffer.alloc(5);
9
+ header[0] = tag;
10
+ header.writeUInt16BE(nameBuffer.length, 1);
11
+ header.writeUInt16BE(value.length, 3);
12
+ return Buffer.concat([header.subarray(0, 3), nameBuffer, header.subarray(3), value]);
13
+ }
14
+
15
+ function languageTaggedValue(language: string, text: string): Buffer {
16
+ const languageBuffer = Buffer.from(language, 'utf-8');
17
+ const textBuffer = Buffer.from(text, 'utf-8');
18
+ const lengths = Buffer.alloc(4);
19
+ lengths.writeUInt16BE(languageBuffer.length, 0);
20
+ lengths.writeUInt16BE(textBuffer.length, 2);
21
+ return Buffer.concat([lengths.subarray(0, 2), languageBuffer, lengths.subarray(2), textBuffer]);
22
+ }
23
+
24
+ function int32(value: number): Buffer {
25
+ const result = Buffer.alloc(4);
26
+ result.writeInt32BE(value);
27
+ return result;
28
+ }
29
+
30
+ tap.test('IPP decodes language-tagged model names and reports AirPrint raster support', async () => {
31
+ const resolution = Buffer.alloc(9);
32
+ resolution.writeInt32BE(300, 0);
33
+ resolution.writeInt32BE(300, 4);
34
+ resolution[8] = 3;
35
+
36
+ const responseBody = Buffer.concat([
37
+ Buffer.from([0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]),
38
+ Buffer.from([0x04]),
39
+ ippAttribute(0x42, 'printer-name', Buffer.from('BRWF889D2A3B017')),
40
+ ippAttribute(0x35, 'printer-make-and-model', languageTaggedValue('en', 'Brother MFC-J5730DW')),
41
+ ippAttribute(0x23, 'printer-state', int32(3)),
42
+ ippAttribute(0x22, 'printer-is-accepting-jobs', Buffer.from([1])),
43
+ ippAttribute(0x49, 'document-format-supported', Buffer.from('image/urf')),
44
+ ippAttribute(0x49, '', Buffer.from('image/pwg-raster')),
45
+ ippAttribute(0x44, 'sides-supported', Buffer.from('one-sided')),
46
+ ippAttribute(0x44, '', Buffer.from('two-sided-long-edge')),
47
+ ippAttribute(0x32, 'printer-resolution-supported', resolution),
48
+ ippAttribute(0x46, 'ipp-versions-supported', Buffer.from('2.0')),
49
+ Buffer.from([0x03]),
50
+ ]);
51
+
52
+ const server = http.createServer((request, response) => {
53
+ request.resume();
54
+ response.writeHead(200, { 'content-type': 'application/ipp' });
55
+ response.end(responseBody);
56
+ });
57
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
58
+ const address = server.address() as import('node:net').AddressInfo;
59
+
60
+ try {
61
+ const client = new IppProtocol('127.0.0.1', address.port, '/ipp/print');
62
+ const capabilities = await client.getPrinterAttributes();
63
+ expect(capabilities.printerMakeAndModel).toEqual('Brother MFC-J5730DW');
64
+ expect(capabilities.airPrintSupported).toEqual(true);
65
+ expect(capabilities.documentFormatSupported).toEqual(['image/urf', 'image/pwg-raster']);
66
+ expect(capabilities.sidesSupported).toEqual(['one-sided', 'two-sided-long-edge']);
67
+ expect(capabilities.resolutionsSupported).toEqual([
68
+ { crossFeed: 300, feed: 300, units: 'dpi' },
69
+ ]);
70
+ } finally {
71
+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
72
+ }
73
+ });
74
+
75
+ export default tap.start();
package/test/test.node.ts CHANGED
@@ -32,6 +32,7 @@ tap.test('should export device factories', async () => {
32
32
 
33
33
  tap.test('should export protocol implementations', async () => {
34
34
  expect(devicemanager.EsclProtocol).toBeDefined();
35
+ expect(devicemanager.BrotherScanProtocol).toBeDefined();
35
36
  expect(devicemanager.SaneProtocol).toBeDefined();
36
37
  expect(devicemanager.IppProtocol).toBeDefined();
37
38
  expect(devicemanager.SnmpProtocol).toBeDefined();
@@ -153,6 +154,22 @@ tap.test('should create Scanner device using factory', async () => {
153
154
  expect(scanFeature.protocol).toEqual('escl');
154
155
  });
155
156
 
157
+ tap.test('duplex-capable scanners default to duplex ADF', async () => {
158
+ const scanner = devicemanager.createScanner({
159
+ id: 'test:brother-scanner:1',
160
+ name: 'Brother Scanner',
161
+ address: '192.168.1.101',
162
+ port: 54921,
163
+ protocol: 'brother',
164
+ txtRecords: {},
165
+ });
166
+ const scanFeature = scanner.selectFeature<devicemanager.ScanFeature>('scan');
167
+ const defaults = (scanFeature as unknown as {
168
+ resolveOptions(options?: devicemanager.IScanOptions): Required<devicemanager.IScanOptions>;
169
+ }).resolveOptions();
170
+ expect(defaults.source).toEqual('adf-duplex');
171
+ });
172
+
156
173
  // Test Printer creation using factory
157
174
  tap.test('should create Printer device using factory', async () => {
158
175
  const printer = devicemanager.createPrinter({
@@ -0,0 +1,123 @@
1
+ import { tap, expect } from '@git.zone/tstest/tapbundle';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { filter, firstValueFrom, take, timeout, toArray } from 'rxjs';
6
+ import {
7
+ DeviceManager,
8
+ type ISmbServerAdapter,
9
+ type ISmbShareProviderOptions,
10
+ } from '../ts/index.js';
11
+
12
+ class FakeSmbServer implements ISmbServerAdapter {
13
+ public running = false;
14
+
15
+ constructor(private readonly options: ISmbShareProviderOptions) {}
16
+
17
+ public async start() {
18
+ this.running = true;
19
+ return {
20
+ host: this.options.host ?? '127.0.0.1',
21
+ port: this.options.port ?? 445,
22
+ address: `smb://${this.options.host ?? '127.0.0.1'}:${this.options.port ?? 445}`,
23
+ shares: this.options.shares.map((share) => share.name),
24
+ };
25
+ }
26
+
27
+ public async stop(): Promise<void> {
28
+ this.running = false;
29
+ }
30
+
31
+ public async status() {
32
+ return {
33
+ running: this.running,
34
+ host: this.options.host,
35
+ port: this.options.port,
36
+ shares: this.options.shares.map((share) => share.name),
37
+ };
38
+ }
39
+
40
+ public getConnectionOptions() {
41
+ return {
42
+ host: this.options.host ?? '127.0.0.1',
43
+ port: this.options.port ?? 445,
44
+ };
45
+ }
46
+ }
47
+
48
+ tap.test('SMB provider emits settled documents through provider and manager RxJS streams', async () => {
49
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'devicemanager-smb-'));
50
+ const sharePath = path.join(tempRoot, 'scans');
51
+ const manager = new DeviceManager({ autoDiscovery: false });
52
+ const options: ISmbShareProviderOptions = {
53
+ host: '127.0.0.1',
54
+ port: 1445,
55
+ shares: [{ name: 'brother-scans', path: sharePath }],
56
+ pollIntervalMs: 20,
57
+ settleTimeMs: 60,
58
+ };
59
+
60
+ const provider = await manager.provideSmbShares(options, {
61
+ serverFactory: (serverOptions) => new FakeSmbServer({ ...options, ...serverOptions }),
62
+ });
63
+
64
+ try {
65
+ const providerEventPromise = firstValueFrom(provider.documents$.pipe(timeout({ first: 3000 })));
66
+ const managerEventPromise = firstValueFrom(manager.events$.pipe(
67
+ filter((event) => event.name === 'smb:document:arrived'),
68
+ timeout({ first: 3000 })
69
+ ));
70
+
71
+ const documentPath = path.join(sharePath, 'nested', 'scan.pdf');
72
+ await fs.mkdir(path.dirname(documentPath), { recursive: true });
73
+ await fs.writeFile(documentPath, 'first');
74
+ await new Promise((resolve) => setTimeout(resolve, 35));
75
+ await fs.appendFile(documentPath, '-complete');
76
+
77
+ const providerEvent = await providerEventPromise;
78
+ const managerEvent = await managerEventPromise;
79
+ expect(providerEvent.shareName).toEqual('brother-scans');
80
+ expect(providerEvent.relativePath).toEqual('nested/scan.pdf');
81
+ expect(providerEvent.size).toEqual('first-complete'.length);
82
+ expect((managerEvent.args[0] as { absolutePath: string }).absolutePath).toEqual(documentPath);
83
+ } finally {
84
+ await manager.stopSmbShareProvider(provider);
85
+ await fs.rm(tempRoot, { recursive: true, force: true });
86
+ }
87
+ });
88
+
89
+ tap.test('SMB provider keeps document events distinct for shares with the same path', async () => {
90
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'devicemanager-smb-shares-'));
91
+ const manager = new DeviceManager({ autoDiscovery: false });
92
+ const options: ISmbShareProviderOptions = {
93
+ host: '127.0.0.1',
94
+ port: 1445,
95
+ shares: [
96
+ { name: 'scanner-a', path: tempRoot },
97
+ { name: 'scanner-b', path: tempRoot },
98
+ ],
99
+ pollIntervalMs: 20,
100
+ settleTimeMs: 40,
101
+ };
102
+
103
+ const provider = await manager.provideSmbShares(options, {
104
+ serverFactory: (serverOptions) => new FakeSmbServer({ ...options, ...serverOptions }),
105
+ });
106
+
107
+ try {
108
+ const eventsPromise = firstValueFrom(provider.documents$.pipe(
109
+ take(2),
110
+ toArray(),
111
+ timeout({ first: 3000 })
112
+ ));
113
+
114
+ await fs.writeFile(path.join(tempRoot, 'scan.pdf'), 'complete');
115
+ const events = await eventsPromise;
116
+ expect(events.map((event) => event.shareName).sort()).toEqual(['scanner-a', 'scanner-b']);
117
+ } finally {
118
+ await manager.stopSmbShareProvider(provider);
119
+ await fs.rm(tempRoot, { recursive: true, force: true });
120
+ }
121
+ });
122
+
123
+ export default tap.start();
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@ecobridge.xyz/devicemanager',
6
- version: '3.1.1',
6
+ version: '3.2.0',
7
7
  description: 'a device manager for talking to devices on network and over usb'
8
8
  }
@@ -3,7 +3,6 @@
3
3
  * A device is a network endpoint that can have multiple features (capabilities)
4
4
  */
5
5
 
6
- import * as plugins from '../plugins.js';
7
6
  import type {
8
7
  TFeatureType,
9
8
  IFeatureInfo,
@@ -11,6 +10,7 @@ import type {
11
10
  } from '../interfaces/feature.interfaces.js';
12
11
  import type { TDeviceStatus, IRetryOptions } from '../interfaces/index.js';
13
12
  import { Feature, type TDeviceReference } from '../features/feature.abstract.js';
13
+ import { ObservableEventEmitter } from '../events/index.js';
14
14
 
15
15
  // ============================================================================
16
16
  // Device Info Interface
@@ -69,7 +69,7 @@ export interface IDeviceCreateOptions {
69
69
  * }
70
70
  * ```
71
71
  */
72
- export class UniversalDevice extends plugins.events.EventEmitter {
72
+ export class UniversalDevice extends ObservableEventEmitter {
73
73
  // ============================================================================
74
74
  // Properties
75
75
  // ============================================================================
@@ -40,6 +40,12 @@ import type {
40
40
  TFeatureType,
41
41
  IDeviceSelector,
42
42
  } from './interfaces/index.js';
43
+ import { ObservableEventEmitter } from './events/index.js';
44
+ import {
45
+ SmbShareProvider,
46
+ type ISmbShareProviderDependencies,
47
+ type ISmbShareProviderOptions,
48
+ } from './providers/index.js';
43
49
 
44
50
  /**
45
51
  * Default device manager options
@@ -132,10 +138,12 @@ function makeDeviceRef(address: string, port: number, name: string): { id: strin
132
138
  * via multiple methods (mDNS, SSDP, IP scan), features are merged into a
133
139
  * single UniversalDevice instance.
134
140
  */
135
- export class DeviceManager extends plugins.events.EventEmitter {
141
+ export class DeviceManager extends ObservableEventEmitter {
136
142
  private mdnsDiscovery: MdnsDiscovery;
137
143
  private ssdpDiscovery: SsdpDiscovery;
138
144
  private _networkScanner: NetworkScanner | null = null;
145
+ private readonly smbProviders = new Set<SmbShareProvider>();
146
+ private readonly smbProviderSubscriptions = new Map<SmbShareProvider, plugins.rxjs.Subscription>();
139
147
 
140
148
  // Devices keyed by IP address for deduplication
141
149
  private devicesByIp: Map<string, UniversalDevice> = new Map();
@@ -772,6 +780,61 @@ export class DeviceManager extends plugins.events.EventEmitter {
772
780
  return this.getDevicesWithFeature('snmp');
773
781
  }
774
782
 
783
+ // ============================================================================
784
+ // Host-side SMB providers
785
+ // ============================================================================
786
+
787
+ /**
788
+ * Start an SMB server and forward its events through this manager as `smb:*`.
789
+ */
790
+ public async provideSmbShares(
791
+ options: ISmbShareProviderOptions,
792
+ dependencies?: ISmbShareProviderDependencies
793
+ ): Promise<SmbShareProvider> {
794
+ const provider = new SmbShareProvider(options, dependencies);
795
+ const subscription = provider.events$.subscribe((event) => {
796
+ this.emit(`smb:${event.name}`, ...event.args);
797
+ });
798
+
799
+ try {
800
+ await provider.start();
801
+ this.smbProviders.add(provider);
802
+ this.smbProviderSubscriptions.set(provider, subscription);
803
+ return provider;
804
+ } catch (error) {
805
+ subscription.unsubscribe();
806
+ throw error;
807
+ }
808
+ }
809
+
810
+ /** Stop one SMB provider created by this manager. */
811
+ public async stopSmbShareProvider(provider: SmbShareProvider): Promise<void> {
812
+ try {
813
+ await provider.stop();
814
+ } finally {
815
+ this.smbProviderSubscriptions.get(provider)?.unsubscribe();
816
+ this.smbProviderSubscriptions.delete(provider);
817
+ this.smbProviders.delete(provider);
818
+ }
819
+ }
820
+
821
+ /** Stop every SMB provider owned by this manager. */
822
+ public async stopAllSmbShareProviders(): Promise<void> {
823
+ const results = await Promise.allSettled(
824
+ [...this.smbProviders].map((provider) => this.stopSmbShareProvider(provider))
825
+ );
826
+ const failures = results
827
+ .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
828
+ .map((result) => result.reason as unknown);
829
+ if (failures.length > 0) {
830
+ throw new AggregateError(failures, 'One or more SMB providers failed to stop cleanly.');
831
+ }
832
+ }
833
+
834
+ public getSmbShareProviders(): SmbShareProvider[] {
835
+ return [...this.smbProviders];
836
+ }
837
+
775
838
  // ============================================================================
776
839
  // Manual Device Addition
777
840
  // ============================================================================
@@ -807,7 +870,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
807
870
  public async addScanner(
808
871
  address: string,
809
872
  port: number,
810
- protocol: 'escl' | 'sane' = 'escl',
873
+ protocol: 'brother' | 'escl' | 'sane' = 'escl',
811
874
  name?: string
812
875
  ): Promise<UniversalDevice> {
813
876
  const feature = new ScanFeature(
@@ -1071,7 +1134,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
1071
1134
  device = await this.addScanner(
1072
1135
  result.address,
1073
1136
  deviceInfo.port,
1074
- deviceInfo.protocol as 'escl' | 'sane',
1137
+ deviceInfo.protocol as 'brother' | 'escl' | 'sane',
1075
1138
  deviceInfo.name
1076
1139
  );
1077
1140
  } else if (deviceInfo.type === 'printer' && deviceInfo.protocol === 'ipp') {
@@ -1098,7 +1161,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
1098
1161
 
1099
1162
  /**
1100
1163
  * Discover scanners in a subnet and add them to the manager.
1101
- * Convenience method that focuses discovery on scanner protocols only (eSCL, SANE).
1164
+ * Convenience method that focuses discovery on scanner protocols only
1165
+ * (Brother native, eSCL/AirScan, and SANE).
1102
1166
  *
1103
1167
  * @param subnet CIDR notation subnet (e.g., '192.168.1.0/24')
1104
1168
  * @param options Optional discovery settings
@@ -1118,6 +1182,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
1118
1182
  ): Promise<UniversalDevice[]> {
1119
1183
  await this.scanNetwork({
1120
1184
  ipRange: subnet,
1185
+ probeBrother: true,
1121
1186
  probeEscl: true,
1122
1187
  probeSane: true,
1123
1188
  probeIpp: false,
@@ -1145,6 +1210,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
1145
1210
  ): Promise<UniversalDevice[]> {
1146
1211
  await this.scanNetwork({
1147
1212
  ipRange: subnet,
1213
+ probeBrother: false,
1148
1214
  probeEscl: false,
1149
1215
  probeSane: false,
1150
1216
  probeIpp: true,
@@ -1217,10 +1283,19 @@ export class DeviceManager extends plugins.events.EventEmitter {
1217
1283
  * Stop discovery and disconnect all devices
1218
1284
  */
1219
1285
  public async shutdown(): Promise<void> {
1220
- await this.stopDiscovery();
1221
- await this.disconnectAll();
1286
+ const results = await Promise.allSettled([
1287
+ this.stopDiscovery(),
1288
+ this.stopAllSmbShareProviders(),
1289
+ this.disconnectAll(),
1290
+ ]);
1222
1291
  this.devicesByIp.clear();
1223
1292
  this.nameSourceByIp.clear();
1293
+ const failures = results
1294
+ .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
1295
+ .map((result) => result.reason as unknown);
1296
+ if (failures.length > 0) {
1297
+ throw new AggregateError(failures, 'DeviceManager shutdown was incomplete.');
1298
+ }
1224
1299
  }
1225
1300
  }
1226
1301