Files
STM32F4-Base/IEC60870-Slave/test/slave_example_loopback_test.cjs
T
STF cd42b4d61d
CI / L0 静态门禁 (pre-commit) (push) Failing after 2m51s
CI / L0.5 主机侧单测 (MSVC) (push) Successful in 18s
添加遥控业务代码
2026-10-30 20:03:19 -07:00

410 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 独立主站字节测试:启动示例自己的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 doubleSignals = Array(8).fill(1);
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];
// 构造单对象45/46命令原文:CA/IOA按小端编码,QU固定为1(短脉冲)。
// type/oa/ioa/state来自用例,select决定S/E;cot/ca默认激活原因6和示例站点7。
// 返回普通字节数组,也用于故意构造不支持类型的兜底请求,不在此执行业务校验。
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)];
// 发送payload并断言紧随其后的应答;除COT/P/N外须回显原请求,TEST位也须保留。
// master负责链路收发及确认,cot/negative描述预期应答;Promise完成表示该帧已核对。
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);
}
// 验证一次选择后执行的真实TCP全流程;monitorIoa为该命令对应的监视方向地址。
// 顺序逐帧读取:选择ACT_CON、执行ACT_CON、COT=11返回信息、执行原文ACT_TERM。
// 链路窗口k=1,只有前一帧处理完才能推进下一帧,可暴露返回信息/终止抢先的问题。
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);
// 站总召依次返回单点、双点、遥测;遥信分组含前两类,遥测分组只含最后一类。
// 每类8点、每帧4点,首尾另有ACT_CON与ACT_TERM。
const types = qoi === 20 ? [1, 3, 11] : qoi === 21 ? [1, 3] : [11];
const count = 2 + types.length * 2;
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 type = types[Math.floor((f - 1) / 2)];
const digital = type !== 11;
const values = type === 1 ? signals : type === 3 ? doubleSignals : measures;
const baseIoa = type === 1 ? 1001 : type === 3 ? 1101 : 2001;
const width = digital ? 4 : 6;
const first = ((f - 1) % 2) * 4;
assert.equal(asdu.length, 6 + width * 4);
assert.deepEqual([...asdu.subarray(0, 6)], [type, 4, 0x80 | qoi, oa, 7, 0]);
for (let p = 0; p < 4; p++) {
const offset = 6 + p * width;
assert.equal(asdu.readUIntLE(offset, 3), baseIoa + first + p);
if (digital) assert.equal(asdu[offset + 3], values[first + p]);
else {
assert.equal(asdu.readInt16LE(offset + 3), values[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'));
assert(help.stdout.includes('Type46 IOA6101..6108'));
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);
// 站总召和遥信分组均须读回双点1103的新位置,其余双点仍为初值1。
doubleSignals[2] = 2;
// 撤销故意使用不同状态值:匹配只要求类型/站点/IOA/OA,不比较命令状态。
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);
// 公共地址错误由模块判定;遥信地址1001不是Type45命令地址,由应用点表返回47。
await commandReply(master, commandRequest(46, 0x74, 6101, 1, true, 6, 65535), 46, true);
await commandReply(master, commandRequest(45, 0x75, 1001, 1, true), 47, true);
// TEST命令须原样保留TEST位并否定,不能启动模拟输出。
const commandTest = commandRequest(45, 0x76, 6001, 0, true); commandTest[2] |= 0x80;
await commandReply(master, commandTest, 7, true);
// 本版仅接手45/46;47及附完整CP56字节的58须继续交给44兜底。
await commandReply(master, commandRequest(47, 0x77, 1001, 1, false), 44, true);
const timedCommand = commandRequest(58, 0x78, 1001, 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 < 12; index++) {
const asdu = await master.readASDU();
(asdu[0] === 101 || asdu[0] === 15 ? counterFrames : generalFrames).push(asdu);
}
assert.equal(generalFrames.length, 8);
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=83 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 single/double GI values, 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; });