385 lines
18 KiB
Plaintext
385 lines
18 KiB
Plaintext
/* 独立主站字节测试:启动示例自己的Windows Net后端,仅连接127.0.0.1随机端口。
|
|
* Node内置模块即可运行,不安装包、不复用待测ASDU编码器,不更改系统时钟。
|
|
*/
|
|
'use strict';
|
|
const assert = require('node:assert/strict');
|
|
const net = require('node:net');
|
|
const { spawn, spawnSync } = require('node:child_process');
|
|
const { once } = require('node:events');
|
|
const { setTimeout: delay } = require('node:timers/promises');
|
|
const executable = process.argv[2];
|
|
if (!executable) throw new Error('Usage: node slave_example_loopback_test.cjs <example.exe>');
|
|
|
|
class Master {
|
|
constructor(socket) {
|
|
this.socket = socket;
|
|
this.buffer = Buffer.alloc(0);
|
|
this.frames = [];
|
|
this.waiters = [];
|
|
this.ns = this.nr = 0;
|
|
socket.on('data', data => {
|
|
this.buffer = Buffer.concat([this.buffer, data]);
|
|
try {
|
|
while (this.buffer.length >= 2) {
|
|
assert.equal(this.buffer[0], 0x68);
|
|
assert(this.buffer[1] >= 4 && this.buffer[1] <= 253);
|
|
const length = this.buffer[1] + 2;
|
|
if (this.buffer.length < length) break;
|
|
const frame = this.buffer.subarray(0, length);
|
|
this.buffer = this.buffer.subarray(length);
|
|
const waiter = this.waiters.shift();
|
|
if (waiter) { clearTimeout(waiter.timer); waiter.resolve(frame); }
|
|
else this.frames.push(frame);
|
|
}
|
|
} catch (error) { this.fail(error); socket.destroy(); }
|
|
});
|
|
socket.on('error', error => this.fail(error));
|
|
socket.on('close', () => this.fail(new Error('Connection closed')));
|
|
}
|
|
fail(error) {
|
|
this.error = error;
|
|
for (const waiter of this.waiters.splice(0)) {
|
|
clearTimeout(waiter.timer);
|
|
waiter.reject(error);
|
|
}
|
|
}
|
|
next() {
|
|
if (this.frames.length) return Promise.resolve(this.frames.shift());
|
|
if (this.error) return Promise.reject(this.error);
|
|
return new Promise((resolve, reject) => {
|
|
const waiter = { resolve, reject };
|
|
waiter.timer = setTimeout(() => {
|
|
this.waiters.splice(this.waiters.indexOf(waiter), 1);
|
|
reject(new Error('Timed out waiting for an APDU'));
|
|
}, 1500);
|
|
this.waiters.push(waiter);
|
|
});
|
|
}
|
|
async control(code, expected) {
|
|
this.socket.write(Buffer.from([0x68, 4, code, 0, 0, 0]));
|
|
for (let i = 0; i < 8; i++) {
|
|
const frame = await this.next();
|
|
if (frame[2] === 1) continue; // 主站I帧的S确认允许穿插。
|
|
assert.deepEqual([...frame], [0x68, 4, expected, 0, 0, 0]);
|
|
return;
|
|
}
|
|
throw new Error('Missing U-frame confirmation');
|
|
}
|
|
frame(payload) {
|
|
const frame = Buffer.alloc(payload.length + 6);
|
|
frame[0] = 0x68;
|
|
frame[1] = payload.length + 4;
|
|
frame.writeUInt16LE(this.ns++ << 1, 2);
|
|
frame.writeUInt16LE(this.nr << 1, 4);
|
|
Buffer.from(payload).copy(frame, 6);
|
|
return frame;
|
|
}
|
|
async send(payload, fragmented = false) {
|
|
const frame = this.frame(payload);
|
|
if (fragmented) {
|
|
this.socket.write(frame.subarray(0, 3));
|
|
await delay(10);
|
|
this.socket.write(frame.subarray(3));
|
|
} else this.socket.write(frame);
|
|
}
|
|
async readASDU() {
|
|
for (let i = 0; i < 8; i++) {
|
|
const frame = await this.next();
|
|
if (frame[2] === 1) continue;
|
|
assert.equal(frame[2] & 1, 0);
|
|
assert.equal(frame.readUInt16LE(2) >> 1, this.nr++);
|
|
assert.equal(frame.readUInt16LE(4) >> 1, this.ns);
|
|
const ack = Buffer.from([0x68, 4, 1, 0, 0, 0]);
|
|
ack.writeUInt16LE(this.nr << 1, 4);
|
|
this.socket.write(ack); // k=1,每帧ACK后服务才可继续发下一帧。
|
|
return frame.subarray(6);
|
|
}
|
|
throw new Error('Missing I frame');
|
|
}
|
|
async close() {
|
|
const closed = once(this.socket, 'close');
|
|
this.socket.end();
|
|
await closed;
|
|
}
|
|
}
|
|
|
|
const signals = [0, 1, 0, 1, 1, 0, 1, 0];
|
|
const measures = [2300, 2310, 2290, 5000, -125, 0, 1234, 42];
|
|
const request = (oa, qoi = 20, ca = 7) => [100, 1, 0x86, oa, ca & 0xff, ca >> 8, 0, 0, 0, qoi];
|
|
const commandRequest = (type, oa, ioa, state, select, cot = 6, ca = 7) =>
|
|
[type, 1, cot, oa, ca & 0xff, ca >> 8, ioa & 0xff, (ioa >> 8) & 0xff, ioa >> 16,
|
|
state | 4 | (select ? 0x80 : 0)];
|
|
|
|
async function commandReply(master, payload, cot, negative = false) {
|
|
await master.send(payload);
|
|
const expected = [...payload]; expected[2] = (payload[2] & 0x80) | cot | (negative ? 0x40 : 0);
|
|
assert.deepEqual([...(await master.readASDU())], expected);
|
|
}
|
|
|
|
async function commandExecute(master, type, oa, ioa, state, monitorIoa) {
|
|
await commandReply(master, commandRequest(type, oa, ioa, state, true), 7);
|
|
const execute = commandRequest(type, oa, ioa, state, false);
|
|
await commandReply(master, execute, 7);
|
|
assert.deepEqual([...(await master.readASDU())],
|
|
[type === 45 ? 1 : 3, 1, 11, oa, 7, 0, monitorIoa & 0xff, (monitorIoa >> 8) & 0xff, 0, state]);
|
|
const terminated = [...execute]; terminated[2] = 10;
|
|
assert.deepEqual([...(await master.readASDU())], terminated);
|
|
}
|
|
const initialCounters = [120000, 345600, 7890, 0, 1, 65535, 2147483647, 42];
|
|
const counterRequest = (oa, qcc = 5, cot = 6, test = false, ca = 7) =>
|
|
[101, 1, cot | (test ? 0x80 : 0), oa, ca & 0xff, ca >> 8, 0, 0, 0, qcc];
|
|
const clockRequest = (oa, cot = 6, test = false, ca = 7) =>
|
|
[103, 1, cot | (test ? 0x80 : 0), oa, ca & 0xff, ca >> 8, 0, 0, 0, 0x39, 0x30, 34, 12, 28, 10, 26];
|
|
let counterSubmitted = 0, counterCompleted = 0;
|
|
|
|
async function readCounters(master, oa, qcc = 5, values = initialCounters, sequence = 0) {
|
|
const group = qcc & 0x3f;
|
|
const first = group === 2 ? 4 : 0, end = group === 1 ? 4 : 8;
|
|
const count = 2 + (end - first) / 4;
|
|
for (let frame = 0; frame < count; frame++) {
|
|
const asdu = await master.readASDU();
|
|
if (frame === 0 || frame === count - 1) {
|
|
assert.deepEqual([...asdu], counterRequest(oa, qcc, frame === 0 ? 7 : 10));
|
|
} else {
|
|
assert.equal(asdu.length, 38);
|
|
assert.deepEqual([...asdu.subarray(0, 6)], [15, 4, group === 5 ? 37 : 37 + group, oa, 7, 0]);
|
|
for (let point = 0; point < 4; point++) {
|
|
const offset = 6 + 8 * point, index = first + 4 * (frame - 1) + point;
|
|
assert.equal(asdu.readUIntLE(offset, 3), 3001 + index);
|
|
assert.equal(asdu.readInt32LE(offset + 3), values[index]);
|
|
assert.equal(asdu[offset + 7], sequence);
|
|
}
|
|
}
|
|
}
|
|
counterSubmitted += count;
|
|
counterCompleted++;
|
|
}
|
|
|
|
async function counterRead(master, oa, qcc = 5, values = initialCounters, sequence = 0, ca = 7) {
|
|
await master.send(counterRequest(oa, qcc, 6, false, ca));
|
|
await readCounters(master, oa, qcc, values, sequence);
|
|
}
|
|
|
|
async function counterReply(master, payload, cot, negative = false) {
|
|
await master.send(payload);
|
|
const expected = [...payload]; expected[2] = (payload[2] & 0x80) | cot | (negative ? 0x40 : 0);
|
|
assert.deepEqual([...(await master.readASDU())], expected);
|
|
counterSubmitted++;
|
|
if (!negative) counterCompleted++;
|
|
}
|
|
async function totalCall(master, oa, fragmented = false, qoi = 20, ca = 7) {
|
|
await master.send(request(oa, qoi, ca), fragmented);
|
|
await readTotalCall(master, oa, qoi);
|
|
}
|
|
|
|
async function readTotalCall(master, oa, qoi = 20, busyRequests = []) {
|
|
const original = request(oa, qoi);
|
|
const count = qoi === 20 ? 6 : 4;
|
|
const busyReplies = busyRequests.map(payload => {
|
|
const expected = [...payload]; expected[2] = 0xc7;
|
|
if (expected[4] === 0xff && expected[5] === 0xff) { expected[4] = 7; expected[5] = 0; }
|
|
return Buffer.from(expected);
|
|
});
|
|
for (let f = 0; f < count || busyReplies.length;) {
|
|
const asdu = await master.readASDU();
|
|
if ((asdu[2] & 0x40) !== 0) {
|
|
const index = busyReplies.findIndex(expected => expected.equals(asdu));
|
|
assert(index >= 0, `Unexpected negative reply: ${asdu.toString('hex')}`);
|
|
busyReplies.splice(index, 1);
|
|
continue;
|
|
}
|
|
assert(f < count, 'Unexpected response after ACT_TERM');
|
|
if (f === 0 || f === count - 1) {
|
|
const expected = [...original];
|
|
expected[2] = f === 0 ? 0x87 : 0x8a;
|
|
assert.deepEqual([...asdu], expected);
|
|
} else {
|
|
const digital = qoi === 20 ? f < 3 : qoi === 21;
|
|
const width = digital ? 4 : 6;
|
|
const first = (qoi === 20 && !digital ? f - 3 : f - 1) * 4;
|
|
assert.equal(asdu.length, 6 + width * 4);
|
|
assert.deepEqual([...asdu.subarray(0, 6)], [digital ? 1 : 11, 4, 0x80 | qoi, oa, 7, 0]);
|
|
for (let p = 0; p < 4; p++) {
|
|
const offset = 6 + p * width;
|
|
assert.equal(asdu.readUIntLE(offset, 3), (digital ? 1001 : 2001) + first + p);
|
|
if (digital) assert.equal(asdu[offset + 3], signals[first + p]);
|
|
else {
|
|
assert.equal(asdu.readInt16LE(offset + 3), measures[first + p]);
|
|
assert.equal(asdu[offset + 5], 0);
|
|
}
|
|
}
|
|
}
|
|
f++;
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const help = spawnSync(executable, ['--help'], { windowsHide: true, encoding: 'utf8' });
|
|
assert.ifError(help.error);
|
|
assert.equal(help.status, 0, help.stderr);
|
|
assert(help.stdout.includes('--run-ms'));
|
|
assert(help.stdout.includes('--clock simulated|system'));
|
|
assert(help.stdout.includes('Type45 IOA6001..6008'));
|
|
const invalid = spawnSync(executable, ['--ca', '0'], { windowsHide: true, encoding: 'utf8' });
|
|
assert.ifError(invalid.error);
|
|
assert.equal(invalid.status, 1);
|
|
const invalidClock = spawnSync(executable, ['--clock', 'invalid'], { windowsHide: true, encoding: 'utf8' });
|
|
assert.ifError(invalidClock.error);
|
|
assert.equal(invalidClock.status, 1);
|
|
const child = spawn(executable, ['--port', '0', '--ca', '7', '--window', '1', '--run-ms', '12000'],
|
|
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let output = '', errors = '';
|
|
const sockets = [];
|
|
child.stdout.on('data', data => { output += data.toString(); });
|
|
child.stderr.on('data', data => { errors += data.toString(); });
|
|
const exit = once(child, 'exit');
|
|
const deadline = setTimeout(() => { child.kill(); }, 20000);
|
|
try {
|
|
const port = await new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error(`No listening port: ${output} ${errors}`)), 2000);
|
|
child.once('error', error => { clearTimeout(timer); reject(error); });
|
|
child.stdout.on('data', () => {
|
|
const match = output.match(/LISTEN 127\.0\.0\.1:(\d+) CA=7/);
|
|
if (match) { clearTimeout(timer); resolve(Number(match[1])); }
|
|
});
|
|
});
|
|
async function connect() {
|
|
const socket = net.connect({ host: '127.0.0.1', port });
|
|
sockets.push(socket);
|
|
const master = new Master(socket);
|
|
await once(socket, 'connect');
|
|
await master.control(7, 0x0b);
|
|
return master;
|
|
}
|
|
const master = await connect();
|
|
// 一次write内的106延时获得和102读取逐条回显,k=1时第二条由HIGH等待首条ACK。
|
|
const unsupportedBatch = [
|
|
[106, 1, 0x86, 0x18, 7, 0, 0, 0, 0, 0x39, 0x30],
|
|
[102, 1, 5, 0x19, 7, 0, 0xe9, 3, 0]
|
|
];
|
|
master.socket.write(Buffer.concat(unsupportedBatch.map(payload => master.frame(payload))));
|
|
for (const payload of unsupportedBatch) {
|
|
const expected = [...payload]; expected[2] = (payload[2] & 0x80) | 0x40 | 44;
|
|
assert.deepEqual([...(await master.readASDU())], expected);
|
|
}
|
|
// 默认模拟时钟:成功、TEST拒绝、错误COT及广播实际CA确认都保持请求时标原文。
|
|
for (const [payload, cot, negative] of [
|
|
[clockRequest(0x10), 7, false],
|
|
[clockRequest(0x11, 6, true), 7, true],
|
|
[clockRequest(0x12, 5), 45, true],
|
|
[clockRequest(0x13, 6, false, 65535), 7, false]
|
|
]) {
|
|
await master.send(payload);
|
|
const expected = [...payload];
|
|
expected[2] = (payload[2] & 0x80) | cot | (negative ? 0x40 : 0);
|
|
expected[4] = 7; expected[5] = 0;
|
|
assert.deepEqual([...(await master.readASDU())], expected);
|
|
}
|
|
// 同批103先保存执行任务,102当场兜底;Try确认必须等已提交的44之后发送。
|
|
const clockBatch = clockRequest(0x14);
|
|
const readBatch = [102, 1, 5, 0x15, 7, 0, 0xe9, 3, 0];
|
|
master.socket.write(Buffer.concat([master.frame(clockBatch), master.frame(readBatch)]));
|
|
const readRejected = [...readBatch]; readRejected[2] = 0x40 | 44;
|
|
assert.deepEqual([...(await master.readASDU())], readRejected);
|
|
const clockConfirmed = [...clockBatch]; clockConfirmed[2] = 7;
|
|
assert.deepEqual([...(await master.readASDU())], clockConfirmed);
|
|
// 遥控默认先选择;k=1验证确认、返回信息和终止逐帧交接,后续总召必须读到新值。
|
|
await commandExecute(master, 45, 0x71, 6001, 1, 1001);
|
|
signals[0] = 1;
|
|
await commandExecute(master, 46, 0x72, 6103, 2, 1103);
|
|
await commandReply(master, commandRequest(45, 0x73, 6002, 0, true), 7);
|
|
await commandReply(master, commandRequest(45, 0x73, 6002, 1, true, 8), 9);
|
|
await commandReply(master, commandRequest(45, 0x73, 6002, 0, false), 7, true);
|
|
await commandReply(master, commandRequest(46, 0x74, 6101, 1, true, 6, 65535), 46, true);
|
|
await commandReply(master, commandRequest(45, 0x75, 6999, 1, true), 47, true);
|
|
const commandTest = commandRequest(45, 0x76, 6001, 0, true); commandTest[2] |= 0x80;
|
|
await commandReply(master, commandTest, 7, true);
|
|
await commandReply(master, commandRequest(47, 0x77, 6001, 1, false), 44, true);
|
|
const timedCommand = commandRequest(58, 0x78, 6001, 0, true).concat([0, 0, 0, 0, 1, 1, 26]);
|
|
await commandReply(master, timedCommand, 44, true);
|
|
await counterRead(master, 0x20);
|
|
await counterReply(master, counterRequest(0x21, 0x45), 7);
|
|
await counterRead(master, 0x22, 5, initialCounters, 1);
|
|
await counterRead(master, 0x23, 1, initialCounters, 1);
|
|
await counterRead(master, 0x24, 2, initialCounters, 1);
|
|
await counterReply(master, counterRequest(0x25, 3), 7, true);
|
|
await counterReply(master, counterRequest(0x26, 4), 7, true);
|
|
await counterReply(master, counterRequest(0x27, 5, 8), 45, true);
|
|
// TEST requests must not freeze or clear real counters.
|
|
await counterReply(master, counterRequest(0x28, 0x85, 6, true), 7, true);
|
|
await counterRead(master, 0x29, 5, initialCounters, 1, 65535);
|
|
await counterReply(master, counterRequest(0x2a, 0x85), 7);
|
|
await counterRead(master, 0x2b, 5, initialCounters, 2);
|
|
await counterReply(master, counterRequest(0x2c, 0x45), 7);
|
|
const zeroCounters = initialCounters.map(() => 0);
|
|
await counterRead(master, 0x2d, 5, zeroCounters, 3);
|
|
await counterReply(master, counterRequest(0x2e, 0xc5), 7);
|
|
await counterRead(master, 0x2f, 5, zeroCounters, 3);
|
|
await totalCall(master, 0x5a, true);
|
|
await totalCall(master, 0x5b, false, 21);
|
|
await totalCall(master, 0x5c, false, 22);
|
|
// 广播请求的确认、数据和终止均按实际本站CA=7核对。
|
|
await totalCall(master, 0x5d, true, 20, 65535);
|
|
await totalCall(master, 0x5e, false, 21, 65535);
|
|
await totalCall(master, 0x5f, false, 22, 65535);
|
|
// 连续提交不同QOI:只执行第一个,后两个否定;结束后由主站重发才执行。
|
|
const burst = [[0x31, 22, 65535], [0x32, 20, 65535], [0x33, 21, 7]];
|
|
master.socket.write(Buffer.concat(burst.map(([oa, qoi, ca]) => master.frame(request(oa, qoi, ca)))));
|
|
await readTotalCall(master, 0x31, 22, burst.slice(1).map(([oa, qoi, ca]) => request(oa, qoi, ca)));
|
|
for (const [oa, qoi] of burst.slice(1)) await totalCall(master, oa, false, qoi);
|
|
for (const [index, value, cot] of [[4, 8, 46], [9, 37, 7], [9, 36, 7], [0, 200, 44]]) {
|
|
const invalidRequest = request(0x55);
|
|
invalidRequest[index] = value;
|
|
if (index === 9) { invalidRequest[4] = invalidRequest[5] = 0xff; }
|
|
await master.send(invalidRequest);
|
|
const expected = [...invalidRequest];
|
|
expected[2] = 0xc0 | cot;
|
|
if (index === 9) { expected[4] = 7; expected[5] = 0; }
|
|
assert.deepEqual([...(await master.readASDU())], expected);
|
|
}
|
|
// 同一接收批次的激活/撤销在驱动业务前按FIFO处理;随后重复撤销应否定确认。
|
|
const activation = request(0x4a, 21, 65535);
|
|
const deactivation = request(0x4a, 21); deactivation[2] = 0x88;
|
|
master.socket.write(Buffer.concat([master.frame(activation), master.frame(deactivation)]));
|
|
const deactCon = [...deactivation]; deactCon[2] = 0x89;
|
|
assert.deepEqual([...(await master.readASDU())], deactCon);
|
|
deactivation[4] = deactivation[5] = 0xff;
|
|
await master.send(deactivation);
|
|
deactCon[2] = 0xc9;
|
|
assert.deepEqual([...(await master.readASDU())], deactCon);
|
|
await master.control(0x13, 0x23);
|
|
await master.control(7, 0x0b);
|
|
// k=1: both instances must make progress, preserving their own response order.
|
|
master.socket.write(Buffer.concat([master.frame(request(0x6b)), master.frame(counterRequest(0x60))]));
|
|
const generalFrames = [], counterFrames = [];
|
|
for (let index = 0; index < 10; index++) {
|
|
const asdu = await master.readASDU();
|
|
(asdu[0] === 101 || asdu[0] === 15 ? counterFrames : generalFrames).push(asdu);
|
|
}
|
|
assert.equal(generalFrames.length, 6);
|
|
assert.equal(counterFrames.length, 4);
|
|
await readTotalCall({ readASDU: async () => generalFrames.shift() }, 0x6b);
|
|
await readCounters({ readASDU: async () => counterFrames.shift() }, 0x60, 5, zeroCounters, 3);
|
|
await master.close();
|
|
const next = await connect();
|
|
await totalCall(next, 0x7c);
|
|
await next.close();
|
|
const [code] = await exit;
|
|
assert.equal(code, 0, `${output}\n${errors}`);
|
|
assert.match(output, /STOP submitted=67 gi_submitted=11 /);
|
|
assert.match(output, /deactivated=1 timeouts=0/);
|
|
assert.match(output, /negative_submitted=\d+ dropped_requests=0/);
|
|
assert.match(output, new RegExp(`counter_submitted=${counterSubmitted} counter_completed=${counterCompleted}`));
|
|
assert.match(output, /clock_executed=3 clock_synchronized=3 clock_submitted=5/);
|
|
assert.match(output, /command_selected=3 command_executed=2 command_completed=2 command_submitted=\d+ command_returns=2/);
|
|
assert.equal(errors, '');
|
|
console.log('Slave example loopback: passed (real TCP, Type45/46 select/execute/return/termination, command cancel/TEST/address/unsupported types, changed GI value, batched 106+102 and 103+102, simulated clock, counters, k=1, STOP/START, reconnect, cleanup)');
|
|
} finally {
|
|
clearTimeout(deadline);
|
|
for (const socket of sockets) socket.destroy();
|
|
if (child.exitCode === null) child.kill();
|
|
}
|
|
}
|
|
|
|
main().catch(error => { console.error(error); process.exitCode = 1; });
|