@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.
- package/changelog.md +25 -0
- 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 +67 -5
- package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +4 -0
- package/dist_ts/discovery/discovery.classes.networkscanner.js +43 -2
- 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 +3 -1
- package/dist_ts/index.js +4 -2
- package/dist_ts/interfaces/feature.interfaces.d.ts +21 -1
- package/dist_ts/interfaces/index.d.ts +21 -3
- package/dist_ts/interfaces/index.js +1 -1
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- 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 +344 -177
- 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/package.json +11 -7
- package/pnpm-workspace.yaml +2 -0
- package/readme.md +98 -4
- 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.node.ts +17 -0
- package/test/test.smb.node.ts +123 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/device/device.classes.device.ts +2 -2
- package/ts/devicemanager.classes.devicemanager.ts +81 -6
- package/ts/discovery/discovery.classes.networkscanner.ts +50 -1
- 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 +20 -0
- package/ts/interfaces/feature.interfaces.ts +22 -1
- package/ts/interfaces/index.ts +22 -3
- package/ts/plugins.ts +2 -1
- package/ts/protocols/index.ts +11 -1
- package/ts/protocols/protocol.brother.ts +648 -0
- package/ts/protocols/protocol.escl.ts +347 -190
- package/ts/protocols/protocol.ipp.ts +24 -1
- package/ts/providers/index.ts +7 -0
- 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();
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
1221
|
-
|
|
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
|
|