Skip to content

Commit 2c596e3

Browse files
authored
Add scripted-transport harness and Modbus golden-frame tests (#18)
1 parent 3d29695 commit 2c596e3

25 files changed

Lines changed: 1356 additions & 48 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ jobs:
4848
- working-directory: node-drivers
4949
run: node test/integration/eip.js
5050

51+
- working-directory: node-drivers
52+
run: node test/integration/cip-connected.js
53+
5154
- uses: actions/setup-python@v5
5255
with:
5356
python-version: '3.11'

src/core/modbus/pdu.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ export default class PDU {
4646
if (Buffer.isBuffer(value) && value.length === 2) {
4747
value.copy(buffer, offset, 0, 2);
4848
} else if (Number.isFinite(value)) {
49-
buffer.writeInt16BE(value, offset);
49+
/** registers are unsigned 16-bit on the wire; mask so negative
50+
* inputs encode as two's complement instead of throwing */
51+
buffer.writeUInt16BE(value & 0xFFFF, offset);
5052
} else {
5153
throw new Error('Modbus write request error: currently supports buffer, array of 2-byte buffers, or array of finite numbers');
5254
}
@@ -58,6 +60,25 @@ export default class PDU {
5860
return buffer;
5961
}
6062

63+
/**
64+
* Function 0x0F layout: fn(1), address(2), quantity of outputs(2),
65+
* byte count(1), coil values packed LSB-first
66+
*/
67+
static EncodeWriteMultipleCoilsRequest(address, values) {
68+
const byteCount = Math.ceil(values.length / 8);
69+
const buffer = Buffer.alloc(6 + byteCount);
70+
buffer.writeUInt8(Functions.WriteMultipleCoils, 0);
71+
buffer.writeUInt16BE(address, 1);
72+
buffer.writeUInt16BE(values.length, 3);
73+
buffer.writeUInt8(byteCount, 5);
74+
for (let i = 0; i < values.length; i++) {
75+
if (values[i]) {
76+
buffer[6 + (i >> 3)] |= 1 << (i & 0b111);
77+
}
78+
}
79+
return buffer;
80+
}
81+
6182
static Decode(buffer, offsetRef, pduLength) {
6283
const fn = PDU.Fn(buffer, offsetRef);
6384
const data = PDU.Data(buffer, offsetRef, pduLength);

src/defragger.js

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,27 @@ export default class Defragger {
88
this._lengthHandler = lengthHandler;
99
}
1010

11+
/**
12+
* Appends data, if given, and returns the next complete frame or null.
13+
* Call again without data to drain any remaining buffered frames.
14+
*/
1115
defrag(data) {
12-
let defraggedData = null;
13-
14-
this._dataLength += data.length;
15-
this._data = Buffer.concat([this._data, data], this._dataLength);
16+
if (data != null && data.length > 0) {
17+
this._dataLength += data.length;
18+
this._data = Buffer.concat([this._data, data], this._dataLength);
19+
}
1620

17-
while (
21+
if (
1822
this._dataLength > 0
1923
&& this._completeHandler(this._data, { current: 0 }, this._dataLength)
2024
) {
2125
const length = this._lengthHandler(this._data, { current: 0 });
22-
defraggedData = this._data.slice(0, length);
26+
const frame = this._data.slice(0, length);
2327
this._dataLength -= length;
2428
this._data = this._data.slice(length);
29+
return frame;
2530
}
2631

27-
return defraggedData;
32+
return null;
2833
}
2934
}

src/layers/Layer.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,14 @@ export default class Layer extends EventEmitter {
145145

146146
static forwardTo(layer, data, info, context) {
147147
if (layer._defragger != null) { // eslint-disable-line no-underscore-dangle
148-
data = layer._defragger.defrag(data); // eslint-disable-line no-underscore-dangle
149-
if (data == null) return;
148+
/** one chunk may complete several frames; forward each one */
149+
let frame = layer._defragger.defrag(data); // eslint-disable-line no-underscore-dangle
150+
while (frame != null) {
151+
layer.emit('data', frame, info, context);
152+
layer.handleData(frame, info, context);
153+
frame = layer._defragger.defrag(); // eslint-disable-line no-underscore-dangle
154+
}
155+
return;
150156
}
151157
layer.emit('data', data, info, context);
152158
layer.handleData(data, info, context);

src/layers/cip/layers/EIP/cpf.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ class Packet {
126126
value.flags.supportsCIPClass0or1UDPBasedConnections = !!getBits(flags, 8, 9);
127127

128128
let nameLength;
129-
for (nameLength = 0; nameLength <= 16; nameLength++) {
129+
for (nameLength = 0; nameLength < 16; nameLength++) {
130130
if (buffer[offsetRef.current + nameLength] === 0) {
131131
break;
132132
}

src/layers/cip/layers/EIP/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,8 @@ export default class EIPLayer extends Layer {
307307
clearTimeout(timeoutHandler);
308308
if (hostsSpecified) {
309309
timeoutHandler = setTimeout(finalizer, resetTimeout);
310-
return;
311-
// return true;
310+
/** keep this callback registered for replies from the remaining hosts */
311+
return true;
312312
}
313313
finalizer();
314314
} else {

src/layers/cip/layers/EIP/packet.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export default class EIPPacket {
158158
buffer.writeUInt16LE(command, OFFSET_COMMAND);
159159
buffer.writeUInt16LE(dataLength, OFFSET_DATA_LENGTH);
160160
buffer.writeUInt32LE(sessionHandle, OFFSET_SESSION_HANDLE);
161-
buffer.writeUInt32LE(status.code, OFFSET_STATUS);
161+
buffer.writeUInt32LE(status, OFFSET_STATUS);
162162
(senderContext || NullSenderContext).copy(buffer, OFFSET_SENDER_CONTEXT, 0, 8);
163163
buffer.writeUInt32LE(options, OFFSET_OPTIONS);
164164
if (dataLength > 0) {

src/layers/cip/layers/Logix5000/index.js

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,14 @@ import {
5353

5454
const DEFAULT_SCOPE = '__DEFAULT_GLOBAL_SCOPE__';
5555

56-
function Logix5000DecodeDataType(buffer, offsetRef, cb) {
56+
function Logix5000DecodeDataType(buffer, offsetRef) {
5757
const startingOffset = offsetRef.current;
58-
const type = EPath.Decode(buffer, offsetRef, null, false, cb);
58+
const segment = EPath.Decode(buffer, offsetRef, null, false);
5959
/** TODO: Why is this necessary? */
6060
if (offsetRef.current - startingOffset < 2) {
6161
offsetRef.current += 1;
6262
}
63-
return type;
63+
return segment;
6464
}
6565

6666
async function readTagFragmented(layer, path, elements) {
@@ -69,20 +69,21 @@ async function readTagFragmented(layer, path, elements) {
6969
const reqData = Buffer.allocUnsafe(6);
7070
reqData.writeUInt16LE(elements, 0);
7171

72-
const offsetRef = { current: 0 };
72+
let requestOffset = 0;
7373
const chunks = [];
7474

7575
while (true) {
76-
reqData.writeUInt32LE(offsetRef.current, 2);
76+
reqData.writeUInt32LE(requestOffset, 2);
7777
const reply = await sendPromise(layer, service, path, reqData, 5000);
7878

79-
/** remove the tag type bytes if already received */
79+
/** each reply starts with the tag type; keep it on the first chunk only */
80+
const offsetRef = { current: 0 };
8081
Logix5000DecodeDataType(reply.data, offsetRef);
8182
const dataTypeOffset = offsetRef.current;
8283
chunks.push(chunks.length > 0 ? reply.data.slice(dataTypeOffset) : reply.data);
8384

8485
if (reply.status.code === GeneralStatusCodes.PartialTransfer) {
85-
offsetRef.current = reply.data.length - dataTypeOffset;
86+
requestOffset += reply.data.length - dataTypeOffset;
8687
} else if (reply.status.code === 0) {
8788
break;
8889
} else {
@@ -101,7 +102,7 @@ async function parseReadTagMemberStructure(layer, structureType, data, offset) {
101102

102103
const template = await layer.readTemplate(structureType.template.id);
103104
if (!template || !Array.isArray(template.members)) {
104-
return new Error(`Unable to read template: ${structureType.template.id}`);
105+
throw new Error(`Unable to read template: ${structureType.template.id}`);
105106
}
106107

107108
const { members } = template;
@@ -153,15 +154,15 @@ async function parseReadTag(layer, scope, tag, elements, data) {
153154
return undefined;
154155
}
155156

156-
let typeInfo;
157-
const offset = Logix5000DecodeDataType(data, 0, (val) => { typeInfo = val.value; });
157+
const offsetRef = { current: 0 };
158+
const typeSegment = Logix5000DecodeDataType(data, offsetRef);
159+
const typeInfo = typeSegment ? typeSegment.value : undefined;
158160

159161
if (!typeInfo) {
160162
throw new Error('Unable to decode data type from read tag response data');
161163
}
162164

163165
const values = [];
164-
const offsetRef = { current: offset };
165166

166167
if (!typeInfo.constructed || typeInfo.abbreviated === false) {
167168
for (let i = 0; i < elements; i++) {
@@ -225,23 +226,27 @@ async function parseReadTag(layer, scope, tag, elements, data) {
225226

226227
function statusHandler(code, extended, cb) {
227228
let error = GenericServiceStatusDescriptions[code];
228-
if (typeof error === 'object' && Buffer.isBuffer(extended) && extended.length >= 0) {
229-
error = error[extended.readUInt16LE(0)];
229+
if (typeof error === 'object') {
230+
if (Buffer.isBuffer(extended) && extended.length >= 2) {
231+
error = error[extended.readUInt16LE(0)];
232+
} else {
233+
error = undefined;
234+
}
230235
}
231236
if (error) {
232237
cb(null, error);
233238
}
234239
}
235240

236241
/** Use driver specific error handling if exists */
237-
async function send(self, service, path, data, callback /* , timeout */) {
242+
async function send(self, service, path, data, callback, timeout) {
238243
try {
239244
const request = new CIPRequest(service, path, data, null, {
240245
serviceNames: SymbolServiceNames,
241246
statusHandler,
242247
});
243248

244-
const response = await self.sendRequest(true, request);
249+
const response = await self.sendRequest(true, request, null, timeout);
245250
// console.log(response);
246251
if (response.status.error) {
247252
callback(response.status.description, response);
@@ -395,11 +400,11 @@ function parseTemplateNameInfo(data, offset, cb) {
395400
// return error;
396401
// }
397402

398-
function scopedGenerator() {
403+
function scopedGenerator(...scopeArgs) {
399404
const separator = '::';
400-
const args = [...arguments].filter((arg) => !!arg);
401-
const preface = args.length > 0 ? args.join(separator) + separator : '';
402-
return () => preface + [...arguments].join(separator);
405+
const scopes = scopeArgs.filter((arg) => !!arg);
406+
const preface = scopes.length > 0 ? scopes.join(separator) + separator : '';
407+
return (...parts) => preface + parts.join(separator);
403408
}
404409

405410
async function getSymbolInstanceID(layer, scope, tag) {
@@ -886,7 +891,7 @@ export default class Logix5000 extends CIPLayer {
886891
}
887892

888893
for (let i = 0; i < sizeOfMasks; i++) {
889-
if (ORmasks[i] < 0 || ORmasks > 0xFF || ANDmasks[i] < 0 || ANDmasks > 0xFF) {
894+
if (ORmasks[i] < 0 || ORmasks[i] > 0xFF || ANDmasks[i] < 0 || ANDmasks[i] > 0xFF) {
890895
resolver.reject('Values in masks must be greater than or equal to zero and less than or equal to 255');
891896
return;
892897
}

src/layers/cip/layers/internal/CIPConnectionLayer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,7 @@ class CIPConnectionLayer extends Layer {
488488
}
489489

490490
handleDestroy() {
491+
stopResend(this);
491492
this._connectionState = 0;
492493
this._sequenceCount = 0;
493494
this.sendInfo = null;

src/layers/cip/layers/internal/CIPInternalLayer.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,8 @@ class CIPInternalLayer extends Layer {
2727
}
2828
}
2929

30-
sendRequest(connected, request, callback) {
30+
sendRequest(connected, request, callback, timeout) {
3131
return CallbackPromise(callback, (resolver) => {
32-
const timeout = null;
33-
3432
const context = this.contextCallback((error, message) => {
3533
try {
3634
if (error) {

0 commit comments

Comments
 (0)