AI_UIAutomation/utils/common/serial_controller.ts

195 lines
7.7 KiB
TypeScript
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.

/**
* 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();
}
}