@fa_yoshinobu/node-red-contrib-plc-comm-slmp 0.2.1
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 +13 -0
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/docsrc/assets/README.md +10 -0
- package/docsrc/assets/node-red-slmp.png +0 -0
- package/docsrc/index.md +11 -0
- package/docsrc/maintainer/ARCHITECTURE.md +36 -0
- package/docsrc/user/USER_GUIDE.md +238 -0
- package/docsrc/user/toc.yml +2 -0
- package/docsrc/validation/reports/README.md +15 -0
- package/examples/flows/README.md +24 -0
- package/examples/flows/slmp-array-string.json +185 -0
- package/examples/flows/slmp-basic-read-write.json +185 -0
- package/examples/flows/slmp-control-error.json +211 -0
- package/examples/flows/slmp-demo.json +260 -0
- package/examples/flows/slmp-device-matrix.json +514 -0
- package/examples/flows/slmp-routing.json +118 -0
- package/examples/flows/slmp-udp-read-write.json +185 -0
- package/lib/index.js +6 -0
- package/lib/slmp/client.js +642 -0
- package/lib/slmp/constants.js +121 -0
- package/lib/slmp/core.js +406 -0
- package/lib/slmp/errors.js +21 -0
- package/lib/slmp/high-level.js +911 -0
- package/lib/slmp/index.js +10 -0
- package/nodes/slmp-connection.html +142 -0
- package/nodes/slmp-connection.js +78 -0
- package/nodes/slmp-read.html +274 -0
- package/nodes/slmp-read.js +207 -0
- package/nodes/slmp-write.html +267 -0
- package/nodes/slmp-write.js +275 -0
- package/package.json +53 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const dgram = require("dgram");
|
|
4
|
+
const net = require("net");
|
|
5
|
+
|
|
6
|
+
const { Command, FrameType, PLCSeries } = require("./constants");
|
|
7
|
+
const {
|
|
8
|
+
ValueError,
|
|
9
|
+
decodeDeviceDwords,
|
|
10
|
+
decodeDeviceWords,
|
|
11
|
+
decodeResponse,
|
|
12
|
+
deviceToString,
|
|
13
|
+
encodeDeviceSpec,
|
|
14
|
+
encodeRequest,
|
|
15
|
+
extractFrameFromBuffer,
|
|
16
|
+
normalizeFrameType,
|
|
17
|
+
normalizePlcSeries,
|
|
18
|
+
normalizeTarget,
|
|
19
|
+
normalizeTransport,
|
|
20
|
+
packBitValues,
|
|
21
|
+
parseDevice,
|
|
22
|
+
resolveDeviceSubcommand,
|
|
23
|
+
unpackBitValues,
|
|
24
|
+
} = require("./core");
|
|
25
|
+
const { SlmpError } = require("./errors");
|
|
26
|
+
|
|
27
|
+
class SlmpClient {
|
|
28
|
+
constructor(options) {
|
|
29
|
+
const source = options || {};
|
|
30
|
+
this.host = String(source.host || "").trim();
|
|
31
|
+
this.port = source.port === undefined ? 5000 : Number(source.port);
|
|
32
|
+
this.transportType = normalizeTransport(source.transport || "tcp");
|
|
33
|
+
this.timeout = Number(source.timeout ?? 3000);
|
|
34
|
+
this.plcSeries = normalizePlcSeries(source.plcSeries || PLCSeries.QL);
|
|
35
|
+
this.frameType = normalizeFrameType(source.frameType || FrameType.FRAME_4E);
|
|
36
|
+
this.defaultTarget = normalizeTarget(source.defaultTarget || source.target);
|
|
37
|
+
this.monitoringTimer = Number(source.monitoringTimer ?? 0x0010);
|
|
38
|
+
this.raiseOnError = source.raiseOnError !== false;
|
|
39
|
+
this.allowConcurrentRequests = Boolean(source.allowConcurrentRequests);
|
|
40
|
+
|
|
41
|
+
if (!this.host) {
|
|
42
|
+
throw new ValueError("host is required");
|
|
43
|
+
}
|
|
44
|
+
if (!Number.isInteger(this.port) || this.port < 1 || this.port > 65535) {
|
|
45
|
+
throw new ValueError(`port out of range (1..65535): ${this.port}`);
|
|
46
|
+
}
|
|
47
|
+
if (!Number.isFinite(this.timeout) || this.timeout <= 0) {
|
|
48
|
+
throw new ValueError(`timeout must be > 0: ${this.timeout}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this._serial = 0;
|
|
52
|
+
this._requestChain = Promise.resolve();
|
|
53
|
+
this._tcpSocket = null;
|
|
54
|
+
this._tcpConnectPromise = null;
|
|
55
|
+
this._tcpBuffer = Buffer.alloc(0);
|
|
56
|
+
this._tcpPending = null;
|
|
57
|
+
this._tcpFrames = [];
|
|
58
|
+
this._tcpPendingBySerial = new Map();
|
|
59
|
+
this._tcpFramesBySerial = new Map();
|
|
60
|
+
this._udpSocket = null;
|
|
61
|
+
this._udpConnectPromise = null;
|
|
62
|
+
this._udpPending = null;
|
|
63
|
+
this._udpPendingBySerial = new Map();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async connect() {
|
|
67
|
+
if (this.transportType === "tcp") {
|
|
68
|
+
return this._connectTcp();
|
|
69
|
+
}
|
|
70
|
+
return this._connectUdp();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async close() {
|
|
74
|
+
const pending = [];
|
|
75
|
+
if (this._tcpSocket) {
|
|
76
|
+
pending.push(
|
|
77
|
+
new Promise((resolve) => {
|
|
78
|
+
const socket = this._tcpSocket;
|
|
79
|
+
this._tcpSocket = null;
|
|
80
|
+
this._tcpConnectPromise = null;
|
|
81
|
+
this._rejectTcpPending(new SlmpError("TCP connection closed"));
|
|
82
|
+
socket.once("close", resolve);
|
|
83
|
+
socket.destroy();
|
|
84
|
+
})
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (this._udpSocket) {
|
|
88
|
+
pending.push(
|
|
89
|
+
new Promise((resolve) => {
|
|
90
|
+
const socket = this._udpSocket;
|
|
91
|
+
this._udpSocket = null;
|
|
92
|
+
this._udpConnectPromise = null;
|
|
93
|
+
this._rejectUdpPending(new SlmpError("UDP socket closed"));
|
|
94
|
+
socket.once("close", resolve);
|
|
95
|
+
socket.close();
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
await Promise.allSettled(pending);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
request(command, subcommand = 0x0000, data = Buffer.alloc(0), options = {}) {
|
|
103
|
+
const allowConcurrent =
|
|
104
|
+
this.frameType === FrameType.FRAME_4E &&
|
|
105
|
+
(options.allowConcurrentRequests === undefined
|
|
106
|
+
? this.allowConcurrentRequests
|
|
107
|
+
: Boolean(options.allowConcurrentRequests));
|
|
108
|
+
|
|
109
|
+
if (allowConcurrent) {
|
|
110
|
+
return this._requestInternal(command, subcommand, data, options);
|
|
111
|
+
}
|
|
112
|
+
const task = this._requestChain.then(() => this._requestInternal(command, subcommand, data, options));
|
|
113
|
+
this._requestChain = task.catch(() => undefined);
|
|
114
|
+
return task;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async rawCommand(command, options = {}) {
|
|
118
|
+
return this.request(command, options.subcommand ?? 0x0000, options.payload ?? Buffer.alloc(0), options);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async readDevices(device, points, options = {}) {
|
|
122
|
+
if (!Number.isInteger(points) || points < 1 || points > 0xffff) {
|
|
123
|
+
throw new ValueError(`points out of range (1..65535): ${points}`);
|
|
124
|
+
}
|
|
125
|
+
const series = options.series ? normalizePlcSeries(options.series) : this.plcSeries;
|
|
126
|
+
const bitUnit = Boolean(options.bitUnit);
|
|
127
|
+
const ref = parseDevice(device);
|
|
128
|
+
const payload = Buffer.concat([encodeDeviceSpec(ref, { series }), numberToBuffer(points, 2)]);
|
|
129
|
+
const response = await this.request(
|
|
130
|
+
Command.DEVICE_READ,
|
|
131
|
+
resolveDeviceSubcommand({ bitUnit, series }),
|
|
132
|
+
payload,
|
|
133
|
+
options
|
|
134
|
+
);
|
|
135
|
+
if (bitUnit) {
|
|
136
|
+
return unpackBitValues(response.data, points);
|
|
137
|
+
}
|
|
138
|
+
const words = decodeDeviceWords(response.data);
|
|
139
|
+
if (words.length !== points) {
|
|
140
|
+
throw new SlmpError(`word count mismatch: expected=${points}, actual=${words.length}`);
|
|
141
|
+
}
|
|
142
|
+
return words;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async writeDevices(device, values, options = {}) {
|
|
146
|
+
const series = options.series ? normalizePlcSeries(options.series) : this.plcSeries;
|
|
147
|
+
const bitUnit = Boolean(options.bitUnit);
|
|
148
|
+
const items = Array.from(values || []);
|
|
149
|
+
if (items.length === 0) {
|
|
150
|
+
throw new ValueError("values must not be empty");
|
|
151
|
+
}
|
|
152
|
+
const ref = parseDevice(device);
|
|
153
|
+
const parts = [encodeDeviceSpec(ref, { series }), numberToBuffer(items.length, 2)];
|
|
154
|
+
if (bitUnit) {
|
|
155
|
+
parts.push(packBitValues(items));
|
|
156
|
+
} else {
|
|
157
|
+
const body = Buffer.alloc(items.length * 2);
|
|
158
|
+
items.forEach((value, index) => {
|
|
159
|
+
body.writeUInt16LE(Number(value) & 0xffff, index * 2);
|
|
160
|
+
});
|
|
161
|
+
parts.push(body);
|
|
162
|
+
}
|
|
163
|
+
await this.request(
|
|
164
|
+
Command.DEVICE_WRITE,
|
|
165
|
+
resolveDeviceSubcommand({ bitUnit, series }),
|
|
166
|
+
Buffer.concat(parts),
|
|
167
|
+
options
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async readRandom({ wordDevices = [], dwordDevices = [], series, ...requestOptions } = {}) {
|
|
172
|
+
const words = Array.from(wordDevices, (device) => parseDevice(device));
|
|
173
|
+
const dwords = Array.from(dwordDevices, (device) => parseDevice(device));
|
|
174
|
+
if (words.length === 0 && dwords.length === 0) {
|
|
175
|
+
throw new ValueError("wordDevices and dwordDevices must not both be empty");
|
|
176
|
+
}
|
|
177
|
+
if (words.length > 0xff || dwords.length > 0xff) {
|
|
178
|
+
throw new ValueError("wordDevices and dwordDevices must be <= 255 each");
|
|
179
|
+
}
|
|
180
|
+
const normalizedSeries = series ? normalizePlcSeries(series) : this.plcSeries;
|
|
181
|
+
const parts = [Buffer.from([words.length, dwords.length])];
|
|
182
|
+
words.forEach((device) => parts.push(encodeDeviceSpec(device, { series: normalizedSeries })));
|
|
183
|
+
dwords.forEach((device) => parts.push(encodeDeviceSpec(device, { series: normalizedSeries })));
|
|
184
|
+
const response = await this.request(
|
|
185
|
+
Command.DEVICE_READ_RANDOM,
|
|
186
|
+
resolveDeviceSubcommand({ bitUnit: false, series: normalizedSeries }),
|
|
187
|
+
Buffer.concat(parts),
|
|
188
|
+
requestOptions
|
|
189
|
+
);
|
|
190
|
+
const expectedLength = words.length * 2 + dwords.length * 4;
|
|
191
|
+
if (response.data.length !== expectedLength) {
|
|
192
|
+
throw new SlmpError(`random read size mismatch: expected=${expectedLength}, actual=${response.data.length}`);
|
|
193
|
+
}
|
|
194
|
+
const wordValues = decodeDeviceWords(response.data.subarray(0, words.length * 2));
|
|
195
|
+
const dwordValues = decodeDeviceDwords(response.data.subarray(words.length * 2));
|
|
196
|
+
return {
|
|
197
|
+
word: Object.fromEntries(words.map((device, index) => [deviceToString(device), wordValues[index]])),
|
|
198
|
+
dword: Object.fromEntries(dwords.map((device, index) => [deviceToString(device), dwordValues[index]])),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async writeRandomWords({ wordValues = {}, dwordValues = {}, series, ...requestOptions } = {}) {
|
|
203
|
+
const normalizedSeries = series ? normalizePlcSeries(series) : this.plcSeries;
|
|
204
|
+
const wordItems = normalizeItems(wordValues);
|
|
205
|
+
const dwordItems = normalizeItems(dwordValues);
|
|
206
|
+
if (wordItems.length === 0 && dwordItems.length === 0) {
|
|
207
|
+
throw new ValueError("wordValues and dwordValues must not both be empty");
|
|
208
|
+
}
|
|
209
|
+
if (wordItems.length > 0xff || dwordItems.length > 0xff) {
|
|
210
|
+
throw new ValueError("wordValues and dwordValues must be <= 255 each");
|
|
211
|
+
}
|
|
212
|
+
const parts = [Buffer.from([wordItems.length, dwordItems.length])];
|
|
213
|
+
wordItems.forEach(([device, value]) => {
|
|
214
|
+
parts.push(encodeDeviceSpec(device, { series: normalizedSeries }));
|
|
215
|
+
parts.push(numberToBuffer(Number(value) & 0xffff, 2));
|
|
216
|
+
});
|
|
217
|
+
dwordItems.forEach(([device, value]) => {
|
|
218
|
+
parts.push(encodeDeviceSpec(device, { series: normalizedSeries }));
|
|
219
|
+
parts.push(numberToBuffer(Number(value) >>> 0, 4));
|
|
220
|
+
});
|
|
221
|
+
await this.request(
|
|
222
|
+
Command.DEVICE_WRITE_RANDOM,
|
|
223
|
+
resolveDeviceSubcommand({ bitUnit: false, series: normalizedSeries }),
|
|
224
|
+
Buffer.concat(parts),
|
|
225
|
+
requestOptions
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async readTypeName(options = {}) {
|
|
230
|
+
const response = await this.request(Command.READ_TYPE_NAME, 0x0000, Buffer.alloc(0), options);
|
|
231
|
+
const text = response.data.subarray(0, 16).toString("ascii").replace(/\0+$/g, "").trim();
|
|
232
|
+
const modelCode = response.data.length >= 18 ? response.data.readUInt16LE(16) : null;
|
|
233
|
+
return {
|
|
234
|
+
raw: response.data,
|
|
235
|
+
model: text,
|
|
236
|
+
modelCode,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
_nextSerial() {
|
|
241
|
+
if (this.frameType !== FrameType.FRAME_4E) {
|
|
242
|
+
this._serial = (this._serial + 1) & 0xffff;
|
|
243
|
+
return this._serial;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (let attempt = 0; attempt <= 0xffff; attempt += 1) {
|
|
247
|
+
this._serial = (this._serial + 1) & 0xffff;
|
|
248
|
+
if (!this._tcpPendingBySerial.has(this._serial) && !this._udpPendingBySerial.has(this._serial)) {
|
|
249
|
+
return this._serial;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
throw new SlmpError("no free 4E serial values are available");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async _requestInternal(command, subcommand, data, options) {
|
|
257
|
+
const serial = options.serial ?? this._nextSerial();
|
|
258
|
+
const target = normalizeTarget(options.target || this.defaultTarget);
|
|
259
|
+
const monitoringTimer = options.monitoringTimer ?? this.monitoringTimer;
|
|
260
|
+
const frame = encodeRequest({
|
|
261
|
+
frameType: this.frameType,
|
|
262
|
+
serial,
|
|
263
|
+
target,
|
|
264
|
+
monitoringTimer,
|
|
265
|
+
command: Number(command),
|
|
266
|
+
subcommand: Number(subcommand),
|
|
267
|
+
data: Buffer.from(data || Buffer.alloc(0)),
|
|
268
|
+
});
|
|
269
|
+
const raw = await this._sendAndReceive(frame, serial);
|
|
270
|
+
const response = decodeResponse(raw, { frameType: this.frameType });
|
|
271
|
+
const shouldRaise = options.raiseOnError === undefined ? this.raiseOnError : Boolean(options.raiseOnError);
|
|
272
|
+
if (shouldRaise && response.endCode !== 0) {
|
|
273
|
+
throw new SlmpError(
|
|
274
|
+
`SLMP error end_code=0x${response.endCode.toString(16).toUpperCase().padStart(4, "0")} command=0x${Number(command)
|
|
275
|
+
.toString(16)
|
|
276
|
+
.toUpperCase()
|
|
277
|
+
.padStart(4, "0")} subcommand=0x${Number(subcommand)
|
|
278
|
+
.toString(16)
|
|
279
|
+
.toUpperCase()
|
|
280
|
+
.padStart(4, "0")}`,
|
|
281
|
+
{ endCode: response.endCode, data: response.data }
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return response;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async _sendAndReceive(frame, serial) {
|
|
288
|
+
await this.connect();
|
|
289
|
+
if (this.transportType === "tcp") {
|
|
290
|
+
if (!this._tcpSocket) {
|
|
291
|
+
throw new SlmpError("TCP socket is not connected");
|
|
292
|
+
}
|
|
293
|
+
const responsePromise = this._awaitTcpFrame(serial);
|
|
294
|
+
await new Promise((resolve, reject) => {
|
|
295
|
+
this._tcpSocket.write(frame, (error) => {
|
|
296
|
+
if (error) {
|
|
297
|
+
reject(new SlmpError(`TCP write failed: ${error.message}`, { cause: error }));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
resolve();
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
return responsePromise;
|
|
304
|
+
}
|
|
305
|
+
return this._sendUdp(frame, serial);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
_connectTcp() {
|
|
309
|
+
if (this._tcpSocket && !this._tcpSocket.destroyed) {
|
|
310
|
+
return Promise.resolve();
|
|
311
|
+
}
|
|
312
|
+
if (this._tcpConnectPromise) {
|
|
313
|
+
return this._tcpConnectPromise;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
this._tcpConnectPromise = new Promise((resolve, reject) => {
|
|
317
|
+
const socket = net.createConnection({ host: this.host, port: this.port });
|
|
318
|
+
let settled = false;
|
|
319
|
+
const timeoutHandle = setTimeout(() => {
|
|
320
|
+
if (!settled) {
|
|
321
|
+
settled = true;
|
|
322
|
+
socket.destroy();
|
|
323
|
+
reject(new SlmpError(`TCP connection timed out to ${this.host}:${this.port}`));
|
|
324
|
+
}
|
|
325
|
+
}, this.timeout);
|
|
326
|
+
|
|
327
|
+
const finalizeResolve = () => {
|
|
328
|
+
if (settled) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
settled = true;
|
|
332
|
+
clearTimeout(timeoutHandle);
|
|
333
|
+
this._tcpSocket = socket;
|
|
334
|
+
this._tcpBuffer = Buffer.alloc(0);
|
|
335
|
+
socket.setNoDelay(true);
|
|
336
|
+
socket.on("data", (chunk) => this._handleTcpData(chunk));
|
|
337
|
+
socket.on("error", (error) => this._handleTcpFailure(error));
|
|
338
|
+
socket.on("close", () => this._handleTcpFailure(new SlmpError("TCP connection closed")));
|
|
339
|
+
resolve();
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const finalizeReject = (error) => {
|
|
343
|
+
if (settled) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
settled = true;
|
|
347
|
+
clearTimeout(timeoutHandle);
|
|
348
|
+
reject(new SlmpError(`TCP connection failed: ${error.message}`, { cause: error }));
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
socket.once("connect", finalizeResolve);
|
|
352
|
+
socket.once("error", finalizeReject);
|
|
353
|
+
}).finally(() => {
|
|
354
|
+
this._tcpConnectPromise = null;
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
return this._tcpConnectPromise;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
_connectUdp() {
|
|
361
|
+
if (this._udpSocket) {
|
|
362
|
+
return Promise.resolve();
|
|
363
|
+
}
|
|
364
|
+
if (this._udpConnectPromise) {
|
|
365
|
+
return this._udpConnectPromise;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
this._udpConnectPromise = new Promise((resolve, reject) => {
|
|
369
|
+
const family = net.isIP(this.host) === 6 ? "udp6" : "udp4";
|
|
370
|
+
const socket = dgram.createSocket(family);
|
|
371
|
+
let settled = false;
|
|
372
|
+
const timeoutHandle = setTimeout(() => {
|
|
373
|
+
if (!settled) {
|
|
374
|
+
settled = true;
|
|
375
|
+
socket.close();
|
|
376
|
+
reject(new SlmpError(`UDP connect timed out for ${this.host}:${this.port}`));
|
|
377
|
+
}
|
|
378
|
+
}, this.timeout);
|
|
379
|
+
|
|
380
|
+
socket.once("error", (error) => {
|
|
381
|
+
if (!settled) {
|
|
382
|
+
settled = true;
|
|
383
|
+
clearTimeout(timeoutHandle);
|
|
384
|
+
reject(new SlmpError(`UDP connection failed: ${error.message}`, { cause: error }));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
this._handleUdpFailure(error);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
socket.connect(this.port, this.host, () => {
|
|
391
|
+
if (settled) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
settled = true;
|
|
395
|
+
clearTimeout(timeoutHandle);
|
|
396
|
+
this._udpSocket = socket;
|
|
397
|
+
socket.on("error", (error) => this._handleUdpFailure(error));
|
|
398
|
+
socket.on("message", (message) => this._handleUdpMessage(message));
|
|
399
|
+
resolve();
|
|
400
|
+
});
|
|
401
|
+
}).finally(() => {
|
|
402
|
+
this._udpConnectPromise = null;
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
return this._udpConnectPromise;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
_handleTcpData(chunk) {
|
|
409
|
+
this._tcpBuffer = Buffer.concat([this._tcpBuffer, Buffer.from(chunk)]);
|
|
410
|
+
while (true) {
|
|
411
|
+
const extracted = extractFrameFromBuffer(this._tcpBuffer, { frameType: this.frameType });
|
|
412
|
+
if (!extracted) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
this._tcpBuffer = Buffer.from(extracted.rest);
|
|
416
|
+
const frame = Buffer.from(extracted.frame);
|
|
417
|
+
if (this.frameType === FrameType.FRAME_4E) {
|
|
418
|
+
const serial = readResponseSerial(frame);
|
|
419
|
+
const pending = this._tcpPendingBySerial.get(serial);
|
|
420
|
+
if (pending) {
|
|
421
|
+
this._tcpPendingBySerial.delete(serial);
|
|
422
|
+
clearTimeout(pending.timeoutHandle);
|
|
423
|
+
pending.resolve(frame);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const queue = this._tcpFramesBySerial.get(serial) || [];
|
|
427
|
+
queue.push(frame);
|
|
428
|
+
this._tcpFramesBySerial.set(serial, queue);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (this._tcpPending) {
|
|
433
|
+
const pending = this._tcpPending;
|
|
434
|
+
this._tcpPending = null;
|
|
435
|
+
clearTimeout(pending.timeoutHandle);
|
|
436
|
+
pending.resolve(frame);
|
|
437
|
+
} else {
|
|
438
|
+
this._tcpFrames.push(frame);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
_awaitTcpFrame(serial) {
|
|
444
|
+
if (this.frameType === FrameType.FRAME_4E) {
|
|
445
|
+
const queued = shiftQueuedFrame(this._tcpFramesBySerial, serial);
|
|
446
|
+
if (queued) {
|
|
447
|
+
return Promise.resolve(queued);
|
|
448
|
+
}
|
|
449
|
+
if (this._tcpPendingBySerial.has(serial)) {
|
|
450
|
+
return Promise.reject(new SlmpError(`another TCP request is already waiting for serial ${serial}`));
|
|
451
|
+
}
|
|
452
|
+
return new Promise((resolve, reject) => {
|
|
453
|
+
const timeoutHandle = setTimeout(() => {
|
|
454
|
+
this._tcpPendingBySerial.delete(serial);
|
|
455
|
+
reject(new SlmpError("TCP communication timeout"));
|
|
456
|
+
}, this.timeout);
|
|
457
|
+
this._tcpPendingBySerial.set(serial, { resolve, reject, timeoutHandle });
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (this._tcpFrames.length > 0) {
|
|
462
|
+
return Promise.resolve(this._tcpFrames.shift());
|
|
463
|
+
}
|
|
464
|
+
if (this._tcpPending) {
|
|
465
|
+
return Promise.reject(new SlmpError("another TCP request is already waiting for a response"));
|
|
466
|
+
}
|
|
467
|
+
return new Promise((resolve, reject) => {
|
|
468
|
+
const timeoutHandle = setTimeout(() => {
|
|
469
|
+
this._tcpPending = null;
|
|
470
|
+
reject(new SlmpError("TCP communication timeout"));
|
|
471
|
+
}, this.timeout);
|
|
472
|
+
this._tcpPending = { resolve, reject, timeoutHandle };
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
_handleTcpFailure(error) {
|
|
477
|
+
this._tcpSocket = null;
|
|
478
|
+
this._tcpBuffer = Buffer.alloc(0);
|
|
479
|
+
this._rejectTcpPending(
|
|
480
|
+
error instanceof SlmpError ? error : new SlmpError(`TCP transport failure: ${error.message}`, { cause: error })
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
_rejectTcpPending(error) {
|
|
485
|
+
if (this._tcpPending) {
|
|
486
|
+
const pending = this._tcpPending;
|
|
487
|
+
this._tcpPending = null;
|
|
488
|
+
clearTimeout(pending.timeoutHandle);
|
|
489
|
+
pending.reject(error);
|
|
490
|
+
}
|
|
491
|
+
for (const [serial, pending] of this._tcpPendingBySerial.entries()) {
|
|
492
|
+
clearTimeout(pending.timeoutHandle);
|
|
493
|
+
pending.reject(error);
|
|
494
|
+
this._tcpPendingBySerial.delete(serial);
|
|
495
|
+
}
|
|
496
|
+
this._tcpFrames = [];
|
|
497
|
+
this._tcpFramesBySerial.clear();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
_sendUdp(frame, serial) {
|
|
501
|
+
return new Promise((resolve, reject) => {
|
|
502
|
+
if (!this._udpSocket) {
|
|
503
|
+
reject(new SlmpError("UDP socket is not connected"));
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (this.frameType === FrameType.FRAME_4E) {
|
|
508
|
+
if (this._udpPendingBySerial.has(serial)) {
|
|
509
|
+
reject(new SlmpError(`another UDP request is already waiting for serial ${serial}`));
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
} else if (this._udpPending) {
|
|
513
|
+
reject(new SlmpError("another UDP request is already waiting for a response"));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const socket = this._udpSocket;
|
|
518
|
+
const timeoutHandle = setTimeout(() => {
|
|
519
|
+
if (this.frameType === FrameType.FRAME_4E) {
|
|
520
|
+
const pending = this._udpPendingBySerial.get(serial);
|
|
521
|
+
if (!pending) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
this._udpPendingBySerial.delete(serial);
|
|
525
|
+
reject(new SlmpError("UDP communication timeout"));
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (!this._udpPending) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
this._udpPending = null;
|
|
532
|
+
reject(new SlmpError("UDP communication timeout"));
|
|
533
|
+
}, this.timeout);
|
|
534
|
+
|
|
535
|
+
if (this.frameType === FrameType.FRAME_4E) {
|
|
536
|
+
this._udpPendingBySerial.set(serial, { resolve, reject, timeoutHandle });
|
|
537
|
+
} else {
|
|
538
|
+
this._udpPending = { resolve, reject, timeoutHandle };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
socket.send(frame, (error) => {
|
|
542
|
+
if (!error) {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (this.frameType === FrameType.FRAME_4E) {
|
|
546
|
+
if (this._udpPendingBySerial.has(serial)) {
|
|
547
|
+
this._udpPendingBySerial.delete(serial);
|
|
548
|
+
clearTimeout(timeoutHandle);
|
|
549
|
+
}
|
|
550
|
+
} else if (this._udpPending) {
|
|
551
|
+
this._udpPending = null;
|
|
552
|
+
clearTimeout(timeoutHandle);
|
|
553
|
+
}
|
|
554
|
+
reject(new SlmpError(`UDP send failed: ${error.message}`, { cause: error }));
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
_handleUdpMessage(message) {
|
|
560
|
+
if (this.frameType === FrameType.FRAME_4E) {
|
|
561
|
+
const serial = readResponseSerial(message);
|
|
562
|
+
const pending = this._udpPendingBySerial.get(serial);
|
|
563
|
+
if (!pending) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
this._udpPendingBySerial.delete(serial);
|
|
567
|
+
clearTimeout(pending.timeoutHandle);
|
|
568
|
+
pending.resolve(Buffer.from(message));
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (!this._udpPending) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const pending = this._udpPending;
|
|
577
|
+
this._udpPending = null;
|
|
578
|
+
clearTimeout(pending.timeoutHandle);
|
|
579
|
+
pending.resolve(Buffer.from(message));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
_handleUdpFailure(error) {
|
|
583
|
+
this._udpSocket = null;
|
|
584
|
+
this._rejectUdpPending(
|
|
585
|
+
error instanceof SlmpError ? error : new SlmpError(`UDP transport failure: ${error.message}`, { cause: error })
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
_rejectUdpPending(error) {
|
|
590
|
+
if (this._udpPending) {
|
|
591
|
+
const pending = this._udpPending;
|
|
592
|
+
this._udpPending = null;
|
|
593
|
+
clearTimeout(pending.timeoutHandle);
|
|
594
|
+
pending.reject(error);
|
|
595
|
+
}
|
|
596
|
+
for (const [serial, pending] of this._udpPendingBySerial.entries()) {
|
|
597
|
+
clearTimeout(pending.timeoutHandle);
|
|
598
|
+
pending.reject(error);
|
|
599
|
+
this._udpPendingBySerial.delete(serial);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function readResponseSerial(frame) {
|
|
605
|
+
return Buffer.from(frame).readUInt16LE(2);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function shiftQueuedFrame(map, serial) {
|
|
609
|
+
const queue = map.get(serial);
|
|
610
|
+
if (!queue || queue.length === 0) {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
const frame = queue.shift();
|
|
614
|
+
if (queue.length === 0) {
|
|
615
|
+
map.delete(serial);
|
|
616
|
+
}
|
|
617
|
+
return frame;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function normalizeItems(values) {
|
|
621
|
+
if (Array.isArray(values)) {
|
|
622
|
+
return values.map(([device, value]) => [parseDevice(device), value]);
|
|
623
|
+
}
|
|
624
|
+
return Object.entries(values || {}).map(([device, value]) => [parseDevice(device), value]);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function numberToBuffer(value, size) {
|
|
628
|
+
const buffer = Buffer.alloc(size);
|
|
629
|
+
if (size === 2) {
|
|
630
|
+
buffer.writeUInt16LE(Number(value) & 0xffff, 0);
|
|
631
|
+
return buffer;
|
|
632
|
+
}
|
|
633
|
+
if (size === 4) {
|
|
634
|
+
buffer.writeUInt32LE(Number(value) >>> 0, 0);
|
|
635
|
+
return buffer;
|
|
636
|
+
}
|
|
637
|
+
throw new ValueError(`unsupported integer size: ${size}`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
module.exports = {
|
|
641
|
+
SlmpClient,
|
|
642
|
+
};
|