@ecobridge.xyz/devicemanager 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitea/workflows/default_tags.yaml +0 -21
- package/.smartconfig.json +69 -0
- package/changelog.md +38 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/device/device.classes.device.d.ts +2 -2
- package/dist_ts/device/device.classes.device.js +3 -3
- package/dist_ts/devicemanager.classes.devicemanager.d.ts +17 -4
- package/dist_ts/devicemanager.classes.devicemanager.js +99 -11
- package/dist_ts/discovery/discovery.classes.mdns.js +46 -11
- package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +5 -1
- package/dist_ts/discovery/discovery.classes.networkscanner.js +44 -3
- package/dist_ts/discovery/discovery.classes.ssdp.d.ts +52 -21
- package/dist_ts/discovery/discovery.classes.ssdp.js +407 -117
- package/dist_ts/events/events.observable.d.ts +15 -0
- package/dist_ts/events/events.observable.js +22 -0
- package/dist_ts/events/index.d.ts +1 -0
- package/dist_ts/events/index.js +2 -0
- package/dist_ts/factories/index.js +7 -5
- package/dist_ts/features/feature.abstract.d.ts +2 -2
- package/dist_ts/features/feature.abstract.js +3 -3
- package/dist_ts/features/feature.print.d.ts +3 -0
- package/dist_ts/features/feature.print.js +17 -2
- package/dist_ts/features/feature.scan.d.ts +5 -2
- package/dist_ts/features/feature.scan.js +98 -27
- package/dist_ts/index.d.ts +4 -2
- package/dist_ts/index.js +4 -2
- package/dist_ts/interfaces/feature.interfaces.d.ts +22 -2
- package/dist_ts/interfaces/index.d.ts +22 -4
- package/dist_ts/interfaces/index.js +1 -1
- package/dist_ts/plugins.d.ts +6 -15
- package/dist_ts/plugins.js +7 -19
- package/dist_ts/protocols/index.d.ts +2 -1
- package/dist_ts/protocols/index.js +4 -2
- package/dist_ts/protocols/protocol.brother.d.ts +67 -0
- package/dist_ts/protocols/protocol.brother.js +560 -0
- package/dist_ts/protocols/protocol.escl.d.ts +13 -4
- package/dist_ts/protocols/protocol.escl.js +345 -178
- package/dist_ts/protocols/protocol.ipp.d.ts +2 -0
- package/dist_ts/protocols/protocol.ipp.js +22 -2
- package/dist_ts/providers/index.d.ts +1 -0
- package/dist_ts/providers/index.js +2 -0
- package/dist_ts/providers/provider.smb.d.ts +60 -0
- package/dist_ts/providers/provider.smb.js +224 -0
- package/license.md +21 -0
- package/package.json +15 -20
- package/pnpm-workspace.yaml +6 -0
- package/readme.md +157 -78
- package/test/test.brother.node.ts +306 -0
- package/test/test.escl.node.ts +233 -0
- package/test/test.ipp.node.ts +75 -0
- package/test/{test.ts → test.node.ts} +37 -45
- package/test/test.smb.node.ts +123 -0
- package/test/test.ssdp.node.ts +978 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/device/device.classes.device.ts +2 -2
- package/ts/devicemanager.classes.devicemanager.ts +125 -12
- package/ts/discovery/discovery.classes.mdns.ts +40 -11
- package/ts/discovery/discovery.classes.networkscanner.ts +51 -2
- package/ts/discovery/discovery.classes.ssdp.ts +493 -130
- package/ts/events/events.observable.ts +31 -0
- package/ts/events/index.ts +4 -0
- package/ts/factories/index.ts +7 -5
- package/ts/features/feature.abstract.ts +2 -2
- package/ts/features/feature.print.ts +16 -1
- package/ts/features/feature.scan.ts +93 -27
- package/ts/index.ts +21 -0
- package/ts/interfaces/feature.interfaces.ts +23 -2
- package/ts/interfaces/index.ts +23 -4
- package/ts/plugins.ts +6 -25
- package/ts/protocols/index.ts +11 -1
- package/ts/protocols/protocol.brother.ts +648 -0
- package/ts/protocols/protocol.escl.ts +348 -191
- package/ts/protocols/protocol.ipp.ts +24 -1
- package/ts/providers/index.ts +7 -0
- package/ts/providers/provider.smb.ts +326 -0
- package/dist_ts/protocols/protocol.ipp.old.d.ts +0 -45
- package/dist_ts/protocols/protocol.ipp.old.js +0 -284
- package/npmextra.json +0 -24
|
@@ -0,0 +1,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();
|
|
@@ -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();
|