/* 独立主站字节测试:启动示例自己的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 '); 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]; 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')); const invalid = spawnSync(executable, ['--ca', '0'], { windowsHide: true, encoding: 'utf8' }); assert.ifError(invalid.error); assert.equal(invalid.status, 1); const child = spawn(executable, ['--port', '0', '--ca', '7', '--window', '1', '--run-ms', '4000'], { 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(); }, 9000); 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(); 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); await totalCall(master, 0x6b); 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=62 gi_submitted=11 /); assert.match(output, /deactivated=1 timeouts=0/); assert.match(output, /negative_submitted=\d+ dropped_requests=0/); assert.equal(errors, ''); console.log('Slave example loopback: passed (real TCP, station/groups and broadcast CA, busy rejection and retry, broadcast/directed deactivation, fragmented input, k=1 ACK, errors, 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; });