195 lines
7.7 KiB
TypeScript
195 lines
7.7 KiB
TypeScript
/**
|
||
* 64 路继电器板串口控制器 —— 昆明创泽 KMCZE-I64O64-V2.5(RS232/RS485,自定义 16 进制协议)。
|
||
*
|
||
* 用途:设备「添加」流程里,用继电器通断模拟按物理按键(配对/复位键)、给被测设备上下电。
|
||
*
|
||
* 串口参数(规格书):无校验 / 1 停止位 / 无流控;波特率 RS232 默认 115200、RS485 默认 9600。
|
||
* 端口与波特率走 env,绝不写死:SERIAL_PORT / SERIAL_BAUD。
|
||
*
|
||
* 帧格式(16 进制):
|
||
* 控制单路: 55 C8 <ch> <01|00> 55 (ch=1..64 的十六进制;01 通 / 00 断)
|
||
* 控制全 64: 55 C8 41 <8 字节状态> 55
|
||
* 读状态: 55 C7 01 00 55 → 1B DB <8字节输入><8字节输出> 0C
|
||
* 查版本: 55 D3 D3 00 55 → AB 2D AA 20 ...(只读,安全)
|
||
* 延时通断: 55 C9 <ch> <01断|02通> <2字节×0.1s> 55
|
||
*/
|
||
import { SerialPort } from 'serialport';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
|
||
const DEFAULT_PORT = '/dev/cu.usbserial-A50285BI';
|
||
// 按钮点击寿命统计:按「板子(串口序列号)→通道」累加,持久化。继电器寿命约 20 万次。
|
||
const PRESS_COUNT_FILE = path.resolve(__dirname, '../../test-plan/relay-press-counts.json');
|
||
const RELAY_LIFESPAN = 200000;
|
||
|
||
export interface SerialOptions {
|
||
port?: string;
|
||
baudRate?: number;
|
||
/** 发送后等待应答的毫秒数(默认 300ms) */
|
||
responseTimeout?: number;
|
||
}
|
||
|
||
export class SerialController {
|
||
private sp: SerialPort | null = null;
|
||
private readonly path: string;
|
||
private readonly baudRate: 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.responseTimeout = opts.responseTimeout ?? 300;
|
||
}
|
||
|
||
get portPath(): string {
|
||
return this.path;
|
||
}
|
||
|
||
/** 板子标识:从串口路径取序列号(如 usbserial-A50285BI → A50285BI),区分多块板;env SERIAL_BOARD_ID 可覆盖。 */
|
||
get boardId(): string {
|
||
if (process.env.SERIAL_BOARD_ID) return process.env.SERIAL_BOARD_ID;
|
||
const m = this.path.match(/usbserial[-_]?([A-Za-z0-9]+)/i);
|
||
return m ? m[1] : this.path.replace(/^.*\//, '');
|
||
}
|
||
|
||
/** 累加该板各通道的按钮点击次数(继电器寿命约 20 万次),持久化到 test-plan/relay-press-counts.json。 */
|
||
private countPress(channels: 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 = this.boardId;
|
||
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);
|
||
});
|
||
}
|
||
|
||
private assertChannel(ch: number): void {
|
||
if (!Number.isInteger(ch) || ch < 1 || ch > 64) throw new Error(`通道号必须是 1..64,收到 ${ch}`);
|
||
}
|
||
|
||
/** 第 ch 路接通(ch: 1..64) */
|
||
relayOn(ch: number): Promise<Buffer> {
|
||
this.assertChannel(ch);
|
||
this.countPress([ch]);
|
||
return this.send([0x55, 0xc8, ch, 0x01, 0x55]);
|
||
}
|
||
|
||
/** 第 ch 路断开(ch: 1..64) */
|
||
relayOff(ch: number): Promise<Buffer> {
|
||
this.assertChannel(ch);
|
||
return this.send([0x55, 0xc8, ch, 0x00, 0x55]);
|
||
}
|
||
|
||
/** 脉冲:接通 ch 路 ms 毫秒后断开 —— 用于模拟"按一下物理按键"。 */
|
||
async pulse(ch: number, ms = 500): Promise<void> {
|
||
await this.relayOn(ch);
|
||
await new Promise((r) => setTimeout(r, ms));
|
||
await this.relayOff(ch);
|
||
}
|
||
|
||
/**
|
||
* 把通道列表编码成 64 路状态掩码(8 字节)。
|
||
* 规格:高位在左、低位在右;最左字节=ch57-64,最右字节=ch1-8。
|
||
* 已实测:ch33→第4字节 0x01、ch34→0x02、ch35→0x04(ch34+35=0x06)。
|
||
*/
|
||
static buildMask(channels: number[]): Buffer {
|
||
const mask = Buffer.alloc(8);
|
||
for (const ch of channels) {
|
||
if (!Number.isInteger(ch) || ch < 1 || ch > 64) throw new Error(`通道号必须是 1..64,收到 ${ch}`);
|
||
const group = Math.floor((ch - 1) / 8); // 0=ch1-8 ... 7=ch57-64
|
||
const byteIdx = 7 - group; // 高位在左
|
||
const bit = (ch - 1) % 8;
|
||
mask[byteIdx] |= 1 << bit;
|
||
}
|
||
return mask;
|
||
}
|
||
|
||
/**
|
||
* 一帧设置全部 64 路状态:列表内通道接通、其余全部断开(用 55 C8 41 命令)。
|
||
* 列表多于一个通道时即"同时按下"(同一帧闭合,无先后)。传 [] 即全断。
|
||
*/
|
||
setChannels(channels: number[]): Promise<Buffer> {
|
||
if (channels.length) this.countPress(channels); // 仅"通"计入点击;allOff([]) 不计
|
||
const mask = SerialController.buildMask(channels);
|
||
return this.send([0x55, 0xc8, 0x41, ...mask, 0x55]);
|
||
}
|
||
|
||
/** 全部继电器断开。 */
|
||
allOff(): Promise<Buffer> {
|
||
return this.setChannels([]);
|
||
}
|
||
|
||
/**
|
||
* 多键脉冲:同时接通 channels 列表 ms 毫秒后全部断开 —— 模拟"同时按住多个物理键"。
|
||
* (如 remote 进配对:同时按凸键+凹键。)
|
||
*/
|
||
async pulseChannels(channels: number[], ms = 500): Promise<void> {
|
||
await this.setChannels(channels);
|
||
await new Promise((r) => setTimeout(r, ms));
|
||
await this.allOff();
|
||
}
|
||
|
||
/** 读 64 路输入(采集)+ 64 路继电器输出状态。返回原始回包(1B DB ... 0C)。 */
|
||
readStatus(): Promise<Buffer> {
|
||
return this.send([0x55, 0xc7, 0x01, 0x00, 0x55]);
|
||
}
|
||
|
||
/** 查询控制器版本(只读,不影响任何继电器)。返回应答里第 5..18 字节是版本字符串。 */
|
||
async queryVersion(): Promise<{ raw: string; version: string }> {
|
||
const resp = await this.send([0x55, 0xd3, 0xd3, 0x00, 0x55]);
|
||
const raw = resp.toString('hex').toUpperCase();
|
||
// 应答:AB 2D AA <版本ASCII...> BA;版本从第 5 字节(index 4)起
|
||
const ascii = resp.length > 5 ? resp.subarray(4, resp.length - 1).toString('ascii').replace(/[^\x20-\x7E]/g, '').trim() : '';
|
||
return { raw, version: ascii };
|
||
}
|
||
}
|
||
|
||
/** 便捷探测:打开 → 查版本 → 关闭。用于确认板子在线与波特率。 */
|
||
export async function probeBoard(opts: SerialOptions = {}): Promise<{ port: string; baudRate: number; alive: boolean; version: string; raw: string }> {
|
||
const c = new SerialController(opts);
|
||
await c.open();
|
||
try {
|
||
const { version, raw } = await c.queryVersion();
|
||
return { port: c.portPath, baudRate: opts.baudRate || parseInt(process.env.SERIAL_BAUD || '115200', 10), alive: raw.length > 0, version, raw };
|
||
} finally {
|
||
await c.close();
|
||
}
|
||
}
|