Files
STM32F4-Base/IEC60870-Slave/test/slave_example_loopback_test.cjs
T
STF e1b0434774
CI / L0 静态门禁 (pre-commit) (push) Failing after 45s
CI / L0.5 主机侧单测 (MSVC) (push) Failing after 43s
补充遥调业务代码
2026-11-01 00:38:09 -07:00

480 lines
25 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全流程;selectBeforeExecute区分SBO和直接点,monitorIoa为返回信息地址。
// 顺序逐帧读取:可选的选择ACT_CON、执行ACT_CON、COT=11返回信息、执行原文ACT_TERM。
// 链路窗口k=1,只有前一帧处理完才能推进下一帧,可暴露返回信息/终止抢先的问题。
async function commandExecute(master, type, oa, ioa, state, monitorIoa, timeTag = null, selectBeforeExecute = true) {
if (selectBeforeExecute) {
const selection = commandRequest(type, oa, ioa, state, true).concat(timeTag ? timeTag() : []);
await commandReply(master, selection, 7);
}
const execute = commandRequest(type, oa, ioa, state, false).concat(timeTag ? timeTag() : []);
await commandReply(master, execute, 7);
assert.deepEqual([...(await master.readASDU())],
[type === 45 || type === 58 ? 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);
}
// 独立构造Type49/62的基础命令元素:有符号16位设定值及QOS,QL固定为0。
const scaledCommandRequest = (oa, ioa, value, select, type = 49) =>
[type, 1, 6, oa, 7, 0, ioa & 0xff, (ioa >> 8) & 0xff, ioa >> 16,
value & 0xff, (value >> 8) & 0xff, select ? 0x80 : 0];
async function scaledCommandExecute(master, oa, ioa, value, monitorIoa, type = 49, timeTag = null, selectBeforeExecute = true) {
if (selectBeforeExecute) {
const selection = scaledCommandRequest(oa, ioa, value, true, type).concat(timeTag ? timeTag() : []);
await commandReply(master, selection, 7);
}
const execute = scaledCommandRequest(oa, ioa, value, false, type).concat(timeTag ? timeTag() : []);
await commandReply(master, execute, 7);
assert.deepEqual([...(await master.readASDU())],
[11, 1, 11, oa, 7, 0, monitorIoa & 0xff, (monitorIoa >> 8) & 0xff, 0,
value & 0xff, (value >> 8) & 0xff, 0]);
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];
// 主站独立计算UTC基准,逐次按单调经过时间生成UTC+8/无SU的CP56,选择与执行不复用旧时标。
function clockTagFactory(payload, baseNs) {
const tag = payload.slice(-7), milliseconds = tag[0] | (tag[1] << 8);
const utcMs = Date.UTC(2000 + tag[6], (tag[5] & 15) - 1, tag[4] & 31,
(tag[3] & 31) - 8 - ((tag[3] >> 7) & 1), tag[2] & 63,
Math.floor(milliseconds / 1000), milliseconds % 1000);
return (adjustMs = 0) => {
const elapsedMs = Number((process.hrtime.bigint() - baseNs) / 1000000n);
const local = new Date(utcMs + elapsedMs + adjustMs + 8 * 60 * 60 * 1000);
const minuteMs = local.getUTCSeconds() * 1000 + local.getUTCMilliseconds();
return [minuteMs & 0xff, minuteMs >> 8, local.getUTCMinutes(), local.getUTCHours(),
local.getUTCDate(), local.getUTCMonth() + 1, local.getUTCFullYear() - 2000];
};
}
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'));
assert(help.stdout.includes('Type49 IOA6301..6308'));
assert(help.stdout.includes('Type58/59/62'));
assert(help.stdout.includes('Select/execute: IOA6001..6007, 6101..6107, 6301..6307'));
assert(help.stdout.includes('Direct execute: IOA6008, 6108, 6308'));
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];
const clockBaseNs = process.hrtime.bigint();
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);
const currentClockTag = clockTagFactory(clockBatch, clockBaseNs);
// 同类型按点选择模式:直接点拒绝SELECT,SBO点拒绝未选择执行;点的类型掩码独立于全局启用。
await commandReply(master, commandRequest(45, 0x80, 6008, 1, true), 7, true);
await commandReply(master, commandRequest(46, 0x81, 6108, 2, true), 7, true);
await commandReply(master, scaledCommandRequest(0x82, 6308, -888, true), 7, true);
await commandReply(master, commandRequest(45, 0x83, 6007, 0, false), 7, true);
await commandReply(master, commandRequest(46, 0x84, 6008, 2, false), 7, true);
await commandExecute(master, 45, 0x85, 6008, 1, 1008, null, false);
signals[7] = 1;
await commandExecute(master, 46, 0x86, 6108, 2, 1108, null, false);
doubleSignals[7] = 2;
await scaledCommandExecute(master, 0x87, 6308, -888, 2008, 49, null, false);
measures[7] = -888;
// 同类型其余点保持SBO;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;
// 设定值先验证应用限值拒绝,再走完整事务;后面的站总召和QOI=22必须读到新运行值。
await commandReply(master, scaledCommandRequest(0x79, 6301, 10001, true), 7, true);
await scaledCommandExecute(master, 0x7a, 6301, -1234, 2001);
measures[0] = -1234;
// 撤销故意使用不同状态值:匹配只要求类型/站点/IOA/OA,不比较命令状态。
await commandReply(master, commandRequest(45, 0x73, 6002, 0, true), 7);
await commandReply(master, commandRequest(45, 0x73, 6002, 1, true, 8), 9);
// 选择已撤销,该点配置为SBO,因此后续执行必须被否定。
await commandReply(master, commandRequest(45, 0x73, 6002, 0, false), 7, true);
// 公共地址错误由模块判定;遥信地址1001不在命令点表内,由库直接返回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);
// 示例仍不启用47;58/59/62使用新鲜时标,确认和终止逐字节保留各自原CP56。
await commandReply(master, commandRequest(47, 0x77, 1001, 1, false), 44, true);
await commandExecute(master, 58, 0x78, 6002, 0, 1002, currentClockTag);
signals[1] = 0;
await commandExecute(master, 59, 0x7b, 6104, 2, 1104, currentClockTag);
doubleSignals[3] = 2;
await scaledCommandExecute(master, 0x7d, 6302, -4321, 2002, 62, currentClockTag);
measures[1] = -4321;
// 过期58与106同批:先断言106,再严格读取完整计数量流程,任何迟到58帧都会失败。
// STOP统计另证明过期选择未执行应用动作;旧58兜底替换为106兜底,总召统计保持原值。
const expiredCommand = commandRequest(58, 0x7e, 6003, 1, true).concat(currentClockTag(-60000));
const barrier = [106, 1, 6, 0x7f, 7, 0, 0, 0, 0, 0, 0];
master.socket.write(Buffer.concat([master.frame(expiredCommand), master.frame(barrier)]));
const barrierReply = [...barrier]; barrierReply[2] = 0x40 | 44;
assert.deepEqual([...(await master.readASDU())], barrierReply);
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/);
// 七次成功选择含一次撤销;六个SBO和三个直接执行均返回值,模式拒绝/越限/过期不启动动作。
assert.match(output, /command_selected=7 command_executed=9 command_completed=9 command_submitted=\d+ command_returns=9 command_time_expired=1 command_time_rejected=0/);
assert.equal(errors, '');
console.log('Slave example loopback: passed (real TCP, per-point direct/SBO modes and type masks, Type45/46/49/58/59/62 execute/return/termination, scaled limits, fresh timestamps and silent expiry, command cancel/TEST/address/unsupported types, changed single/double/scaled 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; });