238 lines
9.7 KiB
TypeScript
238 lines
9.7 KiB
TypeScript
/**
|
|
* 64 路继电器板串口控制器 —— 昆明创泽 KMCZE-I64O64-V2.5(RS232/RS485,MODBUS-RTU 协议)。
|
|
*
|
|
* 用途:设备「添加」流程里,用继电器通断模拟按物理按键(配对/复位键)、给被测设备上下电。
|
|
*
|
|
* **多板组网**:总线上可挂多块板,用 MODBUS 设备 ID 区分(本项目板号 1、2)。每帧首字节即设备 ID,
|
|
* 只有该 ID 的板会响应,因此能在共用串口/总线上单独控制某一块板。设备 ID 走 opts.deviceId
|
|
* 或 env SERIAL_DEVICE_ID,默认 1;每个帧方法也可临时传 deviceId 覆盖。
|
|
*
|
|
* 串口参数(规格书):无校验 / 1 停止位 / 无流控;波特率 115200(MODBUS-RTU 默认)。
|
|
* 端口与波特率走 env,绝不写死:SERIAL_PORT / SERIAL_BAUD。
|
|
*
|
|
* 帧格式(MODBUS-RTU,id=设备ID,末尾小端 CRC16;线圈地址=通道号,1-based):
|
|
* 控制单路: id 05 00 <ch> <FF00 通 | 0000 断> + CRC (功能05 写单线圈)
|
|
* 控制全 64: id 0F 00 01 00 40 08 <8字节线圈位> + CRC (功能0F 写多线圈,起始线圈1,64路)
|
|
* 读状态: id 01 00 01 00 40 + CRC (功能01 读64线圈)
|
|
* 在线探测: 同读状态;回包首字节=id 且功能码非异常(0x81)即在线
|
|
*
|
|
* 参照同目录 relay_control.py(MODBUS-RTU 参考实现)。
|
|
*/
|
|
import { SerialPort } from 'serialport';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
const DEFAULT_PORT = '/dev/cu.usbserial-A50285BI';
|
|
const DEFAULT_DEVICE_ID = 1; // 默认板号(MODBUS 设备 ID)
|
|
// 按钮点击寿命统计:按「板号(MODBUS 设备 ID)→通道」累加,持久化。继电器寿命约 20 万次。
|
|
const PRESS_COUNT_FILE = path.resolve(__dirname, '../../test-plan/relay-press-counts.json');
|
|
const RELAY_LIFESPAN = 200000;
|
|
|
|
/** 计算 MODBUS CRC16(poly 0xA001、init 0xFFFF),返回小端序 2 字节(移植自 relay_control.py)。 */
|
|
export function crc16(data: Buffer | number[]): Buffer {
|
|
let crc = 0xffff;
|
|
for (const b of data) {
|
|
crc ^= b;
|
|
for (let i = 0; i < 8; i++) {
|
|
crc = crc & 1 ? (crc >> 1) ^ 0xa001 : crc >> 1;
|
|
}
|
|
}
|
|
return Buffer.from([crc & 0xff, (crc >> 8) & 0xff]);
|
|
}
|
|
|
|
export interface SerialOptions {
|
|
port?: string;
|
|
baudRate?: number;
|
|
/** 默认目标板号(MODBUS 设备 ID);各帧方法可临时覆盖。默认 1。 */
|
|
deviceId?: number;
|
|
/** 发送后等待应答的毫秒数(默认 300ms) */
|
|
responseTimeout?: number;
|
|
}
|
|
|
|
export class SerialController {
|
|
private sp: SerialPort | null = null;
|
|
private readonly path: string;
|
|
private readonly baudRate: number;
|
|
private readonly deviceId: number;
|
|
private readonly responseTimeout: number;
|
|
|
|
constructor(opts: SerialOptions = {}) {
|
|
this.path = opts.port || process.env.SERIAL_PORT || DEFAULT_PORT;
|
|
this.baudRate = opts.baudRate || parseInt(process.env.SERIAL_BAUD || '115200', 10);
|
|
this.deviceId = opts.deviceId ?? parseInt(process.env.SERIAL_DEVICE_ID || String(DEFAULT_DEVICE_ID), 10);
|
|
this.responseTimeout = opts.responseTimeout ?? 300;
|
|
}
|
|
|
|
get portPath(): string {
|
|
return this.path;
|
|
}
|
|
|
|
/** 该控制器默认目标板号(MODBUS 设备 ID)。 */
|
|
get defaultDeviceId(): number {
|
|
return this.deviceId;
|
|
}
|
|
|
|
/** 累加某板各通道的按钮点击次数(继电器寿命约 20 万次),持久化到 test-plan/relay-press-counts.json。 */
|
|
private countPress(channels: number[], deviceId: number): void {
|
|
try {
|
|
let data: Record<string, Record<string, number>> = {};
|
|
if (fs.existsSync(PRESS_COUNT_FILE)) data = JSON.parse(fs.readFileSync(PRESS_COUNT_FILE, 'utf-8'));
|
|
const board = `板${deviceId}`;
|
|
data[board] = data[board] || {};
|
|
for (const ch of channels) data[board][ch] = (data[board][ch] || 0) + 1;
|
|
fs.mkdirSync(path.dirname(PRESS_COUNT_FILE), { recursive: true });
|
|
fs.writeFileSync(PRESS_COUNT_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
|
} catch { /* 计数失败不影响主流程 */ }
|
|
}
|
|
|
|
open(): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
this.sp = new SerialPort(
|
|
{ path: this.path, baudRate: this.baudRate, parity: 'none', stopBits: 1, dataBits: 8 },
|
|
(err) => (err ? reject(err) : resolve())
|
|
);
|
|
});
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
if (this.sp?.isOpen) await new Promise<void>((r) => this.sp!.close(() => r()));
|
|
this.sp = null;
|
|
}
|
|
|
|
/** 发送一帧并收集 responseTimeout 窗口内的所有回包,返回拼接后的 Buffer。 */
|
|
send(frame: Buffer | number[]): Promise<Buffer> {
|
|
const buf = Buffer.isBuffer(frame) ? frame : Buffer.from(frame);
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.sp?.isOpen) return reject(new Error('串口未打开,请先 open()'));
|
|
const chunks: Buffer[] = [];
|
|
const onData = (d: Buffer) => chunks.push(d);
|
|
this.sp.on('data', onData);
|
|
this.sp.write(buf, (err) => {
|
|
if (err) {
|
|
this.sp?.off('data', onData);
|
|
return reject(err);
|
|
}
|
|
});
|
|
setTimeout(() => {
|
|
this.sp?.off('data', onData);
|
|
resolve(Buffer.concat(chunks));
|
|
}, this.responseTimeout);
|
|
});
|
|
}
|
|
|
|
/** 组一帧 MODBUS-RTU(自动追加小端 CRC16)。 */
|
|
private frame(bytes: number[]): Buffer {
|
|
return Buffer.concat([Buffer.from(bytes), crc16(bytes)]);
|
|
}
|
|
|
|
private assertChannel(ch: number): void {
|
|
if (!Number.isInteger(ch) || ch < 1 || ch > 64) throw new Error(`通道号必须是 1..64,收到 ${ch}`);
|
|
}
|
|
|
|
private assertDeviceId(id: number): void {
|
|
if (!Number.isInteger(id) || id < 1 || id > 247) throw new Error(`设备ID(板号)必须是 1..247,收到 ${id}`);
|
|
}
|
|
|
|
/** 第 ch 路接通(ch: 1..64)。功能05 写单线圈,值 0xFF00。 */
|
|
relayOn(ch: number, deviceId: number = this.deviceId): Promise<Buffer> {
|
|
this.assertChannel(ch);
|
|
this.assertDeviceId(deviceId);
|
|
this.countPress([ch], deviceId);
|
|
return this.send(this.frame([deviceId, 0x05, 0x00, ch, 0xff, 0x00]));
|
|
}
|
|
|
|
/** 第 ch 路断开(ch: 1..64)。功能05 写单线圈,值 0x0000。 */
|
|
relayOff(ch: number, deviceId: number = this.deviceId): Promise<Buffer> {
|
|
this.assertChannel(ch);
|
|
this.assertDeviceId(deviceId);
|
|
return this.send(this.frame([deviceId, 0x05, 0x00, ch, 0x00, 0x00]));
|
|
}
|
|
|
|
/** 脉冲:接通 ch 路 ms 毫秒后断开 —— 用于模拟"按一下物理按键"。 */
|
|
async pulse(ch: number, ms = 500, deviceId: number = this.deviceId): Promise<void> {
|
|
await this.relayOn(ch, deviceId);
|
|
await new Promise((r) => setTimeout(r, ms));
|
|
await this.relayOff(ch, deviceId);
|
|
}
|
|
|
|
/**
|
|
* 把通道列表编码成 MODBUS 写多线圈(功能0F)的 8 字节数据。
|
|
* 规格(MODBUS):byte0 bit0 = 起始线圈(=ch1),bit1=ch2 …… byte7 bit7=ch64(低位在前)。
|
|
* 例:ch1→byte0=0x01、ch33→byte4 bit0=0x01、ch34→byte4 bit1=0x02(ch33+34=0x03)。
|
|
*/
|
|
static buildCoilBytes(channels: number[]): Buffer {
|
|
const bytes = Buffer.alloc(8);
|
|
for (const ch of channels) {
|
|
if (!Number.isInteger(ch) || ch < 1 || ch > 64) throw new Error(`通道号必须是 1..64,收到 ${ch}`);
|
|
const byteIdx = Math.floor((ch - 1) / 8); // 0=ch1-8 ... 7=ch57-64(低位在前)
|
|
const bit = (ch - 1) % 8;
|
|
bytes[byteIdx] |= 1 << bit;
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
/**
|
|
* 一帧设置全部 64 路状态:列表内通道接通、其余全部断开(功能0F 写多线圈,起始线圈1,64路)。
|
|
* 列表多于一个通道时即"同时按下"(同一帧闭合,无先后)。传 [] 即全断。
|
|
*/
|
|
setChannels(channels: number[], deviceId: number = this.deviceId): Promise<Buffer> {
|
|
this.assertDeviceId(deviceId);
|
|
if (channels.length) this.countPress(channels, deviceId); // 仅"通"计入点击;allOff([]) 不计
|
|
const coils = SerialController.buildCoilBytes(channels);
|
|
// id 0F <起始线圈=0x0001> <线圈数=0x0040> <字节数=0x08> <8字节线圈位>
|
|
return this.send(this.frame([deviceId, 0x0f, 0x00, 0x01, 0x00, 0x40, 0x08, ...coils]));
|
|
}
|
|
|
|
/** 全部继电器断开。 */
|
|
allOff(deviceId: number = this.deviceId): Promise<Buffer> {
|
|
return this.setChannels([], deviceId);
|
|
}
|
|
|
|
/**
|
|
* 多键脉冲:同时接通 channels 列表 ms 毫秒后全部断开 —— 模拟"同时按住多个物理键"。
|
|
* (如 remote 进配对:同时按凸键+凹键。)
|
|
*/
|
|
async pulseChannels(channels: number[], ms = 500, deviceId: number = this.deviceId): Promise<void> {
|
|
await this.setChannels(channels, deviceId);
|
|
await new Promise((r) => setTimeout(r, ms));
|
|
await this.allOff(deviceId);
|
|
}
|
|
|
|
/** 读 64 路继电器线圈状态(功能01 读线圈,起始1,64路)。返回原始回包。 */
|
|
readStatus(deviceId: number = this.deviceId): Promise<Buffer> {
|
|
this.assertDeviceId(deviceId);
|
|
return this.send(this.frame([deviceId, 0x01, 0x00, 0x01, 0x00, 0x40]));
|
|
}
|
|
|
|
/**
|
|
* 在线探测:对指定板发一帧读线圈,回包首字节=该板号且功能码非异常(0x81)即视为在线。
|
|
* MODBUS 无独立"版本"命令,故用读线圈做存活/波特率确认。
|
|
*/
|
|
async ping(deviceId: number = this.deviceId): Promise<{ raw: string; alive: boolean; deviceId: number }> {
|
|
const resp = await this.readStatus(deviceId);
|
|
const raw = resp.toString('hex').toUpperCase();
|
|
const alive = resp.length >= 2 && resp[0] === deviceId && resp[1] !== 0x81;
|
|
return { raw, alive, deviceId };
|
|
}
|
|
}
|
|
|
|
/** 便捷探测:打开 → 读线圈存活探测 → 关闭。用于确认某块板在线与波特率。 */
|
|
export async function probeBoard(
|
|
opts: SerialOptions = {}
|
|
): Promise<{ port: string; baudRate: number; deviceId: number; alive: boolean; raw: string }> {
|
|
const c = new SerialController(opts);
|
|
await c.open();
|
|
try {
|
|
const { alive, raw, deviceId } = await c.ping();
|
|
return {
|
|
port: c.portPath,
|
|
baudRate: opts.baudRate || parseInt(process.env.SERIAL_BAUD || '115200', 10),
|
|
deviceId,
|
|
alive,
|
|
raw,
|
|
};
|
|
} finally {
|
|
await c.close();
|
|
}
|
|
}
|