@ecobridge.xyz/devicemanager 3.0.2
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_nottags.yaml +66 -0
- package/.gitea/workflows/default_tags.yaml +124 -0
- package/.vscode/launch.json +11 -0
- package/.vscode/settings.json +26 -0
- package/changelog.md +91 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/device/device.classes.device.d.ts +205 -0
- package/dist_ts/device/device.classes.device.js +344 -0
- package/dist_ts/devicemanager.classes.devicemanager.d.ts +210 -0
- package/dist_ts/devicemanager.classes.devicemanager.js +835 -0
- package/dist_ts/discovery/discovery.classes.homeassistant.d.ts +101 -0
- package/dist_ts/discovery/discovery.classes.homeassistant.js +313 -0
- package/dist_ts/discovery/discovery.classes.mdns.d.ts +90 -0
- package/dist_ts/discovery/discovery.classes.mdns.js +291 -0
- package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +64 -0
- package/dist_ts/discovery/discovery.classes.networkscanner.js +535 -0
- package/dist_ts/discovery/discovery.classes.ssdp.d.ts +112 -0
- package/dist_ts/discovery/discovery.classes.ssdp.js +255 -0
- package/dist_ts/factories/index.d.ts +190 -0
- package/dist_ts/factories/index.js +350 -0
- package/dist_ts/features/feature.abstract.d.ts +118 -0
- package/dist_ts/features/feature.abstract.js +172 -0
- package/dist_ts/features/feature.camera.d.ts +95 -0
- package/dist_ts/features/feature.camera.js +162 -0
- package/dist_ts/features/feature.climate.d.ts +174 -0
- package/dist_ts/features/feature.climate.js +328 -0
- package/dist_ts/features/feature.cover.d.ts +121 -0
- package/dist_ts/features/feature.cover.js +217 -0
- package/dist_ts/features/feature.fan.d.ts +127 -0
- package/dist_ts/features/feature.fan.js +235 -0
- package/dist_ts/features/feature.light.d.ts +156 -0
- package/dist_ts/features/feature.light.js +296 -0
- package/dist_ts/features/feature.lock.d.ts +103 -0
- package/dist_ts/features/feature.lock.js +170 -0
- package/dist_ts/features/feature.playback.d.ts +98 -0
- package/dist_ts/features/feature.playback.js +191 -0
- package/dist_ts/features/feature.power.d.ts +79 -0
- package/dist_ts/features/feature.power.js +173 -0
- package/dist_ts/features/feature.print.d.ts +78 -0
- package/dist_ts/features/feature.print.js +226 -0
- package/dist_ts/features/feature.scan.d.ts +79 -0
- package/dist_ts/features/feature.scan.js +315 -0
- package/dist_ts/features/feature.sensor.d.ts +95 -0
- package/dist_ts/features/feature.sensor.js +147 -0
- package/dist_ts/features/feature.snmp.d.ts +88 -0
- package/dist_ts/features/feature.snmp.js +175 -0
- package/dist_ts/features/feature.switch.d.ts +75 -0
- package/dist_ts/features/feature.switch.js +126 -0
- package/dist_ts/features/feature.volume.d.ts +104 -0
- package/dist_ts/features/feature.volume.js +186 -0
- package/dist_ts/features/index.d.ts +19 -0
- package/dist_ts/features/index.js +23 -0
- package/dist_ts/helpers/helpers.iprange.d.ts +35 -0
- package/dist_ts/helpers/helpers.iprange.js +151 -0
- package/dist_ts/helpers/helpers.retry.d.ts +25 -0
- package/dist_ts/helpers/helpers.retry.js +69 -0
- package/dist_ts/index.d.ts +15 -0
- package/dist_ts/index.js +40 -0
- package/dist_ts/interfaces/feature.interfaces.d.ts +255 -0
- package/dist_ts/interfaces/feature.interfaces.js +6 -0
- package/dist_ts/interfaces/homeassistant.interfaces.d.ts +550 -0
- package/dist_ts/interfaces/homeassistant.interfaces.js +111 -0
- package/dist_ts/interfaces/index.d.ts +330 -0
- package/dist_ts/interfaces/index.js +16 -0
- package/dist_ts/interfaces/smarthome.interfaces.d.ts +282 -0
- package/dist_ts/interfaces/smarthome.interfaces.js +7 -0
- package/dist_ts/paths.d.ts +1 -0
- package/dist_ts/paths.js +3 -0
- package/dist_ts/plugins.d.ts +23 -0
- package/dist_ts/plugins.js +30 -0
- package/dist_ts/protocols/index.d.ts +12 -0
- package/dist_ts/protocols/index.js +21 -0
- package/dist_ts/protocols/protocol.escl.d.ts +58 -0
- package/dist_ts/protocols/protocol.escl.js +364 -0
- package/dist_ts/protocols/protocol.homeassistant.d.ts +248 -0
- package/dist_ts/protocols/protocol.homeassistant.js +611 -0
- package/dist_ts/protocols/protocol.ipp.d.ts +45 -0
- package/dist_ts/protocols/protocol.ipp.js +284 -0
- package/dist_ts/protocols/protocol.nut.d.ts +160 -0
- package/dist_ts/protocols/protocol.nut.js +364 -0
- package/dist_ts/protocols/protocol.sane.d.ts +104 -0
- package/dist_ts/protocols/protocol.sane.js +597 -0
- package/dist_ts/protocols/protocol.snmp.d.ts +133 -0
- package/dist_ts/protocols/protocol.snmp.js +333 -0
- package/dist_ts/protocols/protocol.upnp.d.ts +207 -0
- package/dist_ts/protocols/protocol.upnp.js +442 -0
- package/dist_ts/protocols/protocol.upssnmp.d.ts +160 -0
- package/dist_ts/protocols/protocol.upssnmp.js +263 -0
- package/npmextra.json +24 -0
- package/package.json +39 -0
- package/readme.hints.md +115 -0
- package/readme.md +624 -0
- package/test/test.ts +261 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/device/device.classes.device.ts +454 -0
- package/ts/devicemanager.classes.devicemanager.ts +1219 -0
- package/ts/discovery/discovery.classes.homeassistant.ts +376 -0
- package/ts/discovery/discovery.classes.mdns.ts +347 -0
- package/ts/discovery/discovery.classes.networkscanner.ts +648 -0
- package/ts/discovery/discovery.classes.ssdp.ts +357 -0
- package/ts/factories/index.ts +696 -0
- package/ts/features/feature.abstract.ts +251 -0
- package/ts/features/feature.camera.ts +214 -0
- package/ts/features/feature.climate.ts +407 -0
- package/ts/features/feature.cover.ts +278 -0
- package/ts/features/feature.fan.ts +296 -0
- package/ts/features/feature.light.ts +369 -0
- package/ts/features/feature.lock.ts +223 -0
- package/ts/features/feature.playback.ts +246 -0
- package/ts/features/feature.power.ts +231 -0
- package/ts/features/feature.print.ts +297 -0
- package/ts/features/feature.scan.ts +370 -0
- package/ts/features/feature.sensor.ts +202 -0
- package/ts/features/feature.snmp.ts +235 -0
- package/ts/features/feature.switch.ts +170 -0
- package/ts/features/feature.volume.ts +256 -0
- package/ts/features/index.ts +25 -0
- package/ts/helpers/helpers.iprange.ts +172 -0
- package/ts/helpers/helpers.retry.ts +100 -0
- package/ts/index.ts +165 -0
- package/ts/interfaces/feature.interfaces.ts +359 -0
- package/ts/interfaces/homeassistant.interfaces.ts +666 -0
- package/ts/interfaces/index.ts +421 -0
- package/ts/interfaces/smarthome.interfaces.ts +421 -0
- package/ts/paths.ts +5 -0
- package/ts/plugins.ts +41 -0
- package/ts/protocols/index.ts +59 -0
- package/ts/protocols/protocol.escl.ts +439 -0
- package/ts/protocols/protocol.homeassistant.ts +737 -0
- package/ts/protocols/protocol.ipp.ts +329 -0
- package/ts/protocols/protocol.nut.ts +471 -0
- package/ts/protocols/protocol.sane.ts +694 -0
- package/ts/protocols/protocol.snmp.ts +439 -0
- package/ts/protocols/protocol.upnp.ts +627 -0
- package/ts/protocols/protocol.upssnmp.ts +377 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import type {
|
|
3
|
+
ISaneDevice,
|
|
4
|
+
ISaneOption,
|
|
5
|
+
ISaneParameters,
|
|
6
|
+
IScanOptions,
|
|
7
|
+
IScanResult,
|
|
8
|
+
TColorMode,
|
|
9
|
+
} from '../interfaces/index.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* SANE network protocol RPC codes
|
|
13
|
+
*/
|
|
14
|
+
const enum SaneRpc {
|
|
15
|
+
INIT = 0,
|
|
16
|
+
GET_DEVICES = 1,
|
|
17
|
+
OPEN = 2,
|
|
18
|
+
CLOSE = 3,
|
|
19
|
+
GET_OPTION_DESCRIPTORS = 4,
|
|
20
|
+
CONTROL_OPTION = 5,
|
|
21
|
+
GET_PARAMETERS = 6,
|
|
22
|
+
START = 7,
|
|
23
|
+
CANCEL = 8,
|
|
24
|
+
AUTHORIZE = 9,
|
|
25
|
+
EXIT = 10,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* SANE status codes
|
|
30
|
+
*/
|
|
31
|
+
const enum SaneStatus {
|
|
32
|
+
GOOD = 0,
|
|
33
|
+
UNSUPPORTED = 1,
|
|
34
|
+
CANCELLED = 2,
|
|
35
|
+
DEVICE_BUSY = 3,
|
|
36
|
+
INVAL = 4,
|
|
37
|
+
EOF = 5,
|
|
38
|
+
JAMMED = 6,
|
|
39
|
+
NO_DOCS = 7,
|
|
40
|
+
COVER_OPEN = 8,
|
|
41
|
+
IO_ERROR = 9,
|
|
42
|
+
NO_MEM = 10,
|
|
43
|
+
ACCESS_DENIED = 11,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* SANE option types
|
|
48
|
+
*/
|
|
49
|
+
const enum SaneValueType {
|
|
50
|
+
BOOL = 0,
|
|
51
|
+
INT = 1,
|
|
52
|
+
FIXED = 2,
|
|
53
|
+
STRING = 3,
|
|
54
|
+
BUTTON = 4,
|
|
55
|
+
GROUP = 5,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* SANE option units
|
|
60
|
+
*/
|
|
61
|
+
const enum SaneUnit {
|
|
62
|
+
NONE = 0,
|
|
63
|
+
PIXEL = 1,
|
|
64
|
+
BIT = 2,
|
|
65
|
+
MM = 3,
|
|
66
|
+
DPI = 4,
|
|
67
|
+
PERCENT = 5,
|
|
68
|
+
MICROSECOND = 6,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* SANE constraint types
|
|
73
|
+
*/
|
|
74
|
+
const enum SaneConstraintType {
|
|
75
|
+
NONE = 0,
|
|
76
|
+
RANGE = 1,
|
|
77
|
+
WORD_LIST = 2,
|
|
78
|
+
STRING_LIST = 3,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* SANE control option actions
|
|
83
|
+
*/
|
|
84
|
+
const enum SaneAction {
|
|
85
|
+
GET_VALUE = 0,
|
|
86
|
+
SET_VALUE = 1,
|
|
87
|
+
SET_AUTO = 2,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const SANE_PORT = 6566;
|
|
91
|
+
const SANE_NET_PROTOCOL_VERSION = 3;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Status code to error message mapping
|
|
95
|
+
*/
|
|
96
|
+
const STATUS_MESSAGES: Record<number, string> = {
|
|
97
|
+
[SaneStatus.GOOD]: 'Success',
|
|
98
|
+
[SaneStatus.UNSUPPORTED]: 'Operation not supported',
|
|
99
|
+
[SaneStatus.CANCELLED]: 'Operation cancelled',
|
|
100
|
+
[SaneStatus.DEVICE_BUSY]: 'Device busy',
|
|
101
|
+
[SaneStatus.INVAL]: 'Invalid argument',
|
|
102
|
+
[SaneStatus.EOF]: 'End of file',
|
|
103
|
+
[SaneStatus.JAMMED]: 'Document feeder jammed',
|
|
104
|
+
[SaneStatus.NO_DOCS]: 'No documents in feeder',
|
|
105
|
+
[SaneStatus.COVER_OPEN]: 'Scanner cover open',
|
|
106
|
+
[SaneStatus.IO_ERROR]: 'I/O error',
|
|
107
|
+
[SaneStatus.NO_MEM]: 'Out of memory',
|
|
108
|
+
[SaneStatus.ACCESS_DENIED]: 'Access denied',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* SANE network protocol client
|
|
113
|
+
*/
|
|
114
|
+
export class SaneProtocol {
|
|
115
|
+
private socket: plugins.net.Socket | null = null;
|
|
116
|
+
private address: string;
|
|
117
|
+
private port: number;
|
|
118
|
+
private handle: number = -1;
|
|
119
|
+
private options: ISaneOption[] = [];
|
|
120
|
+
private readBuffer: Buffer = Buffer.alloc(0);
|
|
121
|
+
|
|
122
|
+
constructor(address: string, port: number = SANE_PORT) {
|
|
123
|
+
this.address = address;
|
|
124
|
+
this.port = port;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Connect to SANE daemon
|
|
129
|
+
*/
|
|
130
|
+
public async connect(): Promise<void> {
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
this.socket = plugins.net.createConnection(
|
|
133
|
+
{ host: this.address, port: this.port },
|
|
134
|
+
() => {
|
|
135
|
+
this.init()
|
|
136
|
+
.then(() => resolve())
|
|
137
|
+
.catch(reject);
|
|
138
|
+
}
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
this.socket.on('error', reject);
|
|
142
|
+
this.socket.on('data', (data: Buffer) => {
|
|
143
|
+
this.readBuffer = Buffer.concat([this.readBuffer, data]);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Disconnect from SANE daemon
|
|
150
|
+
*/
|
|
151
|
+
public async disconnect(): Promise<void> {
|
|
152
|
+
if (this.handle >= 0) {
|
|
153
|
+
await this.close();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await this.exit();
|
|
157
|
+
|
|
158
|
+
if (this.socket) {
|
|
159
|
+
this.socket.destroy();
|
|
160
|
+
this.socket = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Initialize connection (SANE_NET_INIT)
|
|
166
|
+
*/
|
|
167
|
+
private async init(): Promise<void> {
|
|
168
|
+
const request = this.buildRequest(SaneRpc.INIT);
|
|
169
|
+
this.writeWord(request, SANE_NET_PROTOCOL_VERSION);
|
|
170
|
+
this.writeString(request, ''); // Username (empty for now)
|
|
171
|
+
|
|
172
|
+
await this.sendRequest(request);
|
|
173
|
+
const response = await this.readResponse();
|
|
174
|
+
|
|
175
|
+
const status = this.readWord(response);
|
|
176
|
+
const version = this.readWord(response);
|
|
177
|
+
|
|
178
|
+
if (status !== SaneStatus.GOOD) {
|
|
179
|
+
throw new Error(`SANE init failed: ${STATUS_MESSAGES[status] || 'Unknown error'}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get available devices (SANE_NET_GET_DEVICES)
|
|
185
|
+
*/
|
|
186
|
+
public async getDevices(): Promise<ISaneDevice[]> {
|
|
187
|
+
const request = this.buildRequest(SaneRpc.GET_DEVICES);
|
|
188
|
+
await this.sendRequest(request);
|
|
189
|
+
const response = await this.readResponse();
|
|
190
|
+
|
|
191
|
+
const status = this.readWord(response);
|
|
192
|
+
if (status !== SaneStatus.GOOD) {
|
|
193
|
+
throw new Error(`Failed to get devices: ${STATUS_MESSAGES[status]}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const devices: ISaneDevice[] = [];
|
|
197
|
+
const count = this.readWord(response);
|
|
198
|
+
|
|
199
|
+
for (let i = 0; i < count; i++) {
|
|
200
|
+
const hasDevice = this.readWord(response);
|
|
201
|
+
if (hasDevice) {
|
|
202
|
+
devices.push({
|
|
203
|
+
name: this.readString(response),
|
|
204
|
+
vendor: this.readString(response),
|
|
205
|
+
model: this.readString(response),
|
|
206
|
+
type: this.readString(response),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return devices;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Open a device (SANE_NET_OPEN)
|
|
216
|
+
*/
|
|
217
|
+
public async open(deviceName: string): Promise<void> {
|
|
218
|
+
const request = this.buildRequest(SaneRpc.OPEN);
|
|
219
|
+
this.writeString(request, deviceName);
|
|
220
|
+
|
|
221
|
+
await this.sendRequest(request);
|
|
222
|
+
const response = await this.readResponse();
|
|
223
|
+
|
|
224
|
+
const status = this.readWord(response);
|
|
225
|
+
if (status !== SaneStatus.GOOD) {
|
|
226
|
+
// Check for authorization required
|
|
227
|
+
const resource = this.readString(response);
|
|
228
|
+
if (resource) {
|
|
229
|
+
throw new Error(`Authorization required for: ${resource}`);
|
|
230
|
+
}
|
|
231
|
+
throw new Error(`Failed to open device: ${STATUS_MESSAGES[status]}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.handle = this.readWord(response);
|
|
235
|
+
this.readString(response); // resource (should be empty on success)
|
|
236
|
+
|
|
237
|
+
// Get option descriptors
|
|
238
|
+
await this.getOptionDescriptors();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Close the current device (SANE_NET_CLOSE)
|
|
243
|
+
*/
|
|
244
|
+
public async close(): Promise<void> {
|
|
245
|
+
if (this.handle < 0) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const request = this.buildRequest(SaneRpc.CLOSE);
|
|
250
|
+
this.writeWord(request, this.handle);
|
|
251
|
+
|
|
252
|
+
await this.sendRequest(request);
|
|
253
|
+
await this.readResponse(); // Just read to clear buffer
|
|
254
|
+
|
|
255
|
+
this.handle = -1;
|
|
256
|
+
this.options = [];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Exit connection (SANE_NET_EXIT)
|
|
261
|
+
*/
|
|
262
|
+
private async exit(): Promise<void> {
|
|
263
|
+
const request = this.buildRequest(SaneRpc.EXIT);
|
|
264
|
+
await this.sendRequest(request);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get option descriptors (SANE_NET_GET_OPTION_DESCRIPTORS)
|
|
269
|
+
*/
|
|
270
|
+
private async getOptionDescriptors(): Promise<void> {
|
|
271
|
+
const request = this.buildRequest(SaneRpc.GET_OPTION_DESCRIPTORS);
|
|
272
|
+
this.writeWord(request, this.handle);
|
|
273
|
+
|
|
274
|
+
await this.sendRequest(request);
|
|
275
|
+
const response = await this.readResponse();
|
|
276
|
+
|
|
277
|
+
const count = this.readWord(response);
|
|
278
|
+
this.options = [];
|
|
279
|
+
|
|
280
|
+
for (let i = 0; i < count; i++) {
|
|
281
|
+
const hasOption = this.readWord(response);
|
|
282
|
+
if (!hasOption) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const option: ISaneOption = {
|
|
287
|
+
name: this.readString(response),
|
|
288
|
+
title: this.readString(response),
|
|
289
|
+
description: this.readString(response),
|
|
290
|
+
type: this.mapValueType(this.readWord(response)),
|
|
291
|
+
unit: this.mapUnit(this.readWord(response)),
|
|
292
|
+
size: this.readWord(response),
|
|
293
|
+
capabilities: this.readWord(response),
|
|
294
|
+
constraintType: this.mapConstraintType(this.readWord(response)),
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// Read constraint based on type
|
|
298
|
+
if (option.constraintType === 'range') {
|
|
299
|
+
option.constraint = {
|
|
300
|
+
range: {
|
|
301
|
+
min: this.readWord(response),
|
|
302
|
+
max: this.readWord(response),
|
|
303
|
+
quant: this.readWord(response),
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
} else if (option.constraintType === 'word_list') {
|
|
307
|
+
const wordCount = this.readWord(response);
|
|
308
|
+
const words: number[] = [];
|
|
309
|
+
for (let j = 0; j < wordCount; j++) {
|
|
310
|
+
words.push(this.readWord(response));
|
|
311
|
+
}
|
|
312
|
+
option.constraint = { wordList: words };
|
|
313
|
+
} else if (option.constraintType === 'string_list') {
|
|
314
|
+
const strings: string[] = [];
|
|
315
|
+
let str: string;
|
|
316
|
+
while ((str = this.readString(response)) !== '') {
|
|
317
|
+
strings.push(str);
|
|
318
|
+
}
|
|
319
|
+
option.constraint = { stringList: strings };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
this.options.push(option);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Set an option value
|
|
328
|
+
*/
|
|
329
|
+
public async setOption(name: string, value: unknown): Promise<void> {
|
|
330
|
+
const optionIndex = this.options.findIndex((o) => o.name === name);
|
|
331
|
+
if (optionIndex < 0) {
|
|
332
|
+
throw new Error(`Unknown option: ${name}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const option = this.options[optionIndex];
|
|
336
|
+
const request = this.buildRequest(SaneRpc.CONTROL_OPTION);
|
|
337
|
+
|
|
338
|
+
this.writeWord(request, this.handle);
|
|
339
|
+
this.writeWord(request, optionIndex);
|
|
340
|
+
this.writeWord(request, SaneAction.SET_VALUE);
|
|
341
|
+
this.writeWord(request, option.type === 'string' ? (value as string).length + 1 : option.size);
|
|
342
|
+
|
|
343
|
+
// Write value based on type
|
|
344
|
+
if (option.type === 'bool' || option.type === 'int') {
|
|
345
|
+
this.writeWord(request, value as number);
|
|
346
|
+
} else if (option.type === 'fixed') {
|
|
347
|
+
this.writeWord(request, Math.round((value as number) * 65536));
|
|
348
|
+
} else if (option.type === 'string') {
|
|
349
|
+
this.writeString(request, value as string);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
await this.sendRequest(request);
|
|
353
|
+
const response = await this.readResponse();
|
|
354
|
+
|
|
355
|
+
const status = this.readWord(response);
|
|
356
|
+
if (status !== SaneStatus.GOOD) {
|
|
357
|
+
throw new Error(`Failed to set option ${name}: ${STATUS_MESSAGES[status]}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Get scan parameters (SANE_NET_GET_PARAMETERS)
|
|
363
|
+
*/
|
|
364
|
+
public async getParameters(): Promise<ISaneParameters> {
|
|
365
|
+
const request = this.buildRequest(SaneRpc.GET_PARAMETERS);
|
|
366
|
+
this.writeWord(request, this.handle);
|
|
367
|
+
|
|
368
|
+
await this.sendRequest(request);
|
|
369
|
+
const response = await this.readResponse();
|
|
370
|
+
|
|
371
|
+
const status = this.readWord(response);
|
|
372
|
+
if (status !== SaneStatus.GOOD) {
|
|
373
|
+
throw new Error(`Failed to get parameters: ${STATUS_MESSAGES[status]}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const formatCode = this.readWord(response);
|
|
377
|
+
const format = this.mapFormat(formatCode);
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
format,
|
|
381
|
+
lastFrame: this.readWord(response) === 1,
|
|
382
|
+
bytesPerLine: this.readWord(response),
|
|
383
|
+
pixelsPerLine: this.readWord(response),
|
|
384
|
+
lines: this.readWord(response),
|
|
385
|
+
depth: this.readWord(response),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Start scanning (SANE_NET_START)
|
|
391
|
+
*/
|
|
392
|
+
public async start(): Promise<{ port: number; byteOrder: 'little' | 'big' }> {
|
|
393
|
+
const request = this.buildRequest(SaneRpc.START);
|
|
394
|
+
this.writeWord(request, this.handle);
|
|
395
|
+
|
|
396
|
+
await this.sendRequest(request);
|
|
397
|
+
const response = await this.readResponse();
|
|
398
|
+
|
|
399
|
+
const status = this.readWord(response);
|
|
400
|
+
if (status !== SaneStatus.GOOD) {
|
|
401
|
+
throw new Error(`Failed to start scan: ${STATUS_MESSAGES[status]}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const port = this.readWord(response);
|
|
405
|
+
const byteOrder = this.readWord(response) === 0x1234 ? 'little' : 'big';
|
|
406
|
+
this.readString(response); // resource
|
|
407
|
+
|
|
408
|
+
return { port, byteOrder };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Cancel scanning (SANE_NET_CANCEL)
|
|
413
|
+
*/
|
|
414
|
+
public async cancel(): Promise<void> {
|
|
415
|
+
const request = this.buildRequest(SaneRpc.CANCEL);
|
|
416
|
+
this.writeWord(request, this.handle);
|
|
417
|
+
|
|
418
|
+
await this.sendRequest(request);
|
|
419
|
+
await this.readResponse();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Perform a complete scan
|
|
424
|
+
*/
|
|
425
|
+
public async scan(options: IScanOptions): Promise<IScanResult> {
|
|
426
|
+
// Configure scan options
|
|
427
|
+
await this.configureOptions(options);
|
|
428
|
+
|
|
429
|
+
// Get parameters
|
|
430
|
+
const params = await this.getParameters();
|
|
431
|
+
|
|
432
|
+
// Start scan and get data port
|
|
433
|
+
const { port, byteOrder } = await this.start();
|
|
434
|
+
|
|
435
|
+
// Connect to data port and read image data
|
|
436
|
+
const imageData = await this.readImageData(port, params, byteOrder);
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
data: imageData,
|
|
440
|
+
format: options.format ?? 'png',
|
|
441
|
+
width: params.pixelsPerLine,
|
|
442
|
+
height: params.lines,
|
|
443
|
+
resolution: options.resolution ?? 300,
|
|
444
|
+
colorMode: options.colorMode ?? 'color',
|
|
445
|
+
mimeType: `image/${options.format ?? 'png'}`,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Configure scan options based on IScanOptions
|
|
451
|
+
*/
|
|
452
|
+
private async configureOptions(options: IScanOptions): Promise<void> {
|
|
453
|
+
// Set resolution
|
|
454
|
+
if (options.resolution) {
|
|
455
|
+
const resOption = this.options.find((o) => o.name === 'resolution');
|
|
456
|
+
if (resOption) {
|
|
457
|
+
await this.setOption('resolution', options.resolution);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Set color mode
|
|
462
|
+
if (options.colorMode) {
|
|
463
|
+
const modeOption = this.options.find((o) => o.name === 'mode');
|
|
464
|
+
if (modeOption) {
|
|
465
|
+
const modeValue = this.mapColorMode(options.colorMode);
|
|
466
|
+
await this.setOption('mode', modeValue);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Set scan area
|
|
471
|
+
if (options.area) {
|
|
472
|
+
const tlxOption = this.options.find((o) => o.name === 'tl-x');
|
|
473
|
+
const tlyOption = this.options.find((o) => o.name === 'tl-y');
|
|
474
|
+
const brxOption = this.options.find((o) => o.name === 'br-x');
|
|
475
|
+
const bryOption = this.options.find((o) => o.name === 'br-y');
|
|
476
|
+
|
|
477
|
+
if (tlxOption) await this.setOption('tl-x', options.area.x);
|
|
478
|
+
if (tlyOption) await this.setOption('tl-y', options.area.y);
|
|
479
|
+
if (brxOption) await this.setOption('br-x', options.area.x + options.area.width);
|
|
480
|
+
if (bryOption) await this.setOption('br-y', options.area.y + options.area.height);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Set source
|
|
484
|
+
if (options.source) {
|
|
485
|
+
const sourceOption = this.options.find((o) => o.name === 'source');
|
|
486
|
+
if (sourceOption) {
|
|
487
|
+
await this.setOption('source', options.source);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Read image data from data port
|
|
494
|
+
*/
|
|
495
|
+
private async readImageData(
|
|
496
|
+
port: number,
|
|
497
|
+
params: ISaneParameters,
|
|
498
|
+
_byteOrder: 'little' | 'big'
|
|
499
|
+
): Promise<Buffer> {
|
|
500
|
+
return new Promise((resolve, reject) => {
|
|
501
|
+
const dataSocket = plugins.net.createConnection(
|
|
502
|
+
{ host: this.address, port },
|
|
503
|
+
() => {
|
|
504
|
+
const chunks: Buffer[] = [];
|
|
505
|
+
|
|
506
|
+
dataSocket.on('data', (data: Buffer) => {
|
|
507
|
+
// Data format: 4 bytes length + data
|
|
508
|
+
// Read records until length is 0xFFFFFFFF (end marker)
|
|
509
|
+
let offset = 0;
|
|
510
|
+
while (offset < data.length) {
|
|
511
|
+
if (offset + 4 > data.length) {
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const length = data.readUInt32BE(offset);
|
|
516
|
+
offset += 4;
|
|
517
|
+
|
|
518
|
+
if (length === 0xffffffff) {
|
|
519
|
+
// End of data
|
|
520
|
+
dataSocket.destroy();
|
|
521
|
+
resolve(Buffer.concat(chunks));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (offset + length > data.length) {
|
|
526
|
+
// Incomplete record, wait for more data
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
chunks.push(data.subarray(offset, offset + length));
|
|
531
|
+
offset += length;
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
dataSocket.on('end', () => {
|
|
536
|
+
resolve(Buffer.concat(chunks));
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
dataSocket.on('error', reject);
|
|
540
|
+
}
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
dataSocket.on('error', reject);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Map color mode to SANE mode string
|
|
549
|
+
*/
|
|
550
|
+
private mapColorMode(mode: TColorMode): string {
|
|
551
|
+
switch (mode) {
|
|
552
|
+
case 'color':
|
|
553
|
+
return 'Color';
|
|
554
|
+
case 'grayscale':
|
|
555
|
+
return 'Gray';
|
|
556
|
+
case 'blackwhite':
|
|
557
|
+
return 'Lineart';
|
|
558
|
+
default:
|
|
559
|
+
return 'Color';
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Map SANE format code to string
|
|
565
|
+
*/
|
|
566
|
+
private mapFormat(code: number): ISaneParameters['format'] {
|
|
567
|
+
const formats: Record<number, ISaneParameters['format']> = {
|
|
568
|
+
0: 'gray',
|
|
569
|
+
1: 'rgb',
|
|
570
|
+
2: 'red',
|
|
571
|
+
3: 'green',
|
|
572
|
+
4: 'blue',
|
|
573
|
+
};
|
|
574
|
+
return formats[code] ?? 'gray';
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Map value type code to string
|
|
579
|
+
*/
|
|
580
|
+
private mapValueType(code: number): ISaneOption['type'] {
|
|
581
|
+
const types: Record<number, ISaneOption['type']> = {
|
|
582
|
+
[SaneValueType.BOOL]: 'bool',
|
|
583
|
+
[SaneValueType.INT]: 'int',
|
|
584
|
+
[SaneValueType.FIXED]: 'fixed',
|
|
585
|
+
[SaneValueType.STRING]: 'string',
|
|
586
|
+
[SaneValueType.BUTTON]: 'button',
|
|
587
|
+
[SaneValueType.GROUP]: 'group',
|
|
588
|
+
};
|
|
589
|
+
return types[code] ?? 'int';
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Map unit code to string
|
|
594
|
+
*/
|
|
595
|
+
private mapUnit(code: number): ISaneOption['unit'] {
|
|
596
|
+
const units: Record<number, ISaneOption['unit']> = {
|
|
597
|
+
[SaneUnit.NONE]: 'none',
|
|
598
|
+
[SaneUnit.PIXEL]: 'pixel',
|
|
599
|
+
[SaneUnit.BIT]: 'bit',
|
|
600
|
+
[SaneUnit.MM]: 'mm',
|
|
601
|
+
[SaneUnit.DPI]: 'dpi',
|
|
602
|
+
[SaneUnit.PERCENT]: 'percent',
|
|
603
|
+
[SaneUnit.MICROSECOND]: 'microsecond',
|
|
604
|
+
};
|
|
605
|
+
return units[code] ?? 'none';
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Map constraint type code to string
|
|
610
|
+
*/
|
|
611
|
+
private mapConstraintType(code: number): ISaneOption['constraintType'] {
|
|
612
|
+
const types: Record<number, ISaneOption['constraintType']> = {
|
|
613
|
+
[SaneConstraintType.NONE]: 'none',
|
|
614
|
+
[SaneConstraintType.RANGE]: 'range',
|
|
615
|
+
[SaneConstraintType.WORD_LIST]: 'word_list',
|
|
616
|
+
[SaneConstraintType.STRING_LIST]: 'string_list',
|
|
617
|
+
};
|
|
618
|
+
return types[code] ?? 'none';
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// =========================================================================
|
|
622
|
+
// Low-level protocol helpers
|
|
623
|
+
// =========================================================================
|
|
624
|
+
|
|
625
|
+
private buildRequest(rpc: SaneRpc): Buffer[] {
|
|
626
|
+
const chunks: Buffer[] = [];
|
|
627
|
+
const header = Buffer.alloc(4);
|
|
628
|
+
header.writeUInt32BE(rpc, 0);
|
|
629
|
+
chunks.push(header);
|
|
630
|
+
return chunks;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private writeWord(chunks: Buffer[], value: number): void {
|
|
634
|
+
const buf = Buffer.alloc(4);
|
|
635
|
+
buf.writeUInt32BE(value >>> 0, 0);
|
|
636
|
+
chunks.push(buf);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
private writeString(chunks: Buffer[], str: string): void {
|
|
640
|
+
const strBuf = Buffer.from(str + '\0', 'utf-8');
|
|
641
|
+
this.writeWord(chunks, strBuf.length);
|
|
642
|
+
chunks.push(strBuf);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private async sendRequest(chunks: Buffer[]): Promise<void> {
|
|
646
|
+
if (!this.socket) {
|
|
647
|
+
throw new Error('Not connected');
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const data = Buffer.concat(chunks);
|
|
651
|
+
return new Promise((resolve, reject) => {
|
|
652
|
+
this.socket!.write(data, (err) => {
|
|
653
|
+
if (err) reject(err);
|
|
654
|
+
else resolve();
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private async readResponse(): Promise<{ buffer: Buffer; offset: number }> {
|
|
660
|
+
// Wait for data
|
|
661
|
+
await this.waitForData(4);
|
|
662
|
+
|
|
663
|
+
return { buffer: this.readBuffer, offset: 0 };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private async waitForData(minBytes: number): Promise<void> {
|
|
667
|
+
const startTime = Date.now();
|
|
668
|
+
const timeout = 30000;
|
|
669
|
+
|
|
670
|
+
while (this.readBuffer.length < minBytes) {
|
|
671
|
+
if (Date.now() - startTime > timeout) {
|
|
672
|
+
throw new Error('Timeout waiting for SANE response');
|
|
673
|
+
}
|
|
674
|
+
await plugins.smartdelay.delayFor(10);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
private readWord(response: { buffer: Buffer; offset: number }): number {
|
|
679
|
+
const value = response.buffer.readUInt32BE(response.offset);
|
|
680
|
+
response.offset += 4;
|
|
681
|
+
return value;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private readString(response: { buffer: Buffer; offset: number }): string {
|
|
685
|
+
const length = this.readWord(response);
|
|
686
|
+
if (length === 0) {
|
|
687
|
+
return '';
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const str = response.buffer.toString('utf-8', response.offset, response.offset + length - 1);
|
|
691
|
+
response.offset += length;
|
|
692
|
+
return str;
|
|
693
|
+
}
|
|
694
|
+
}
|