feat(relay): 串口层切 MODBUS-RTU,支持双开发板(板1/板2)寻址
参照 relay_control.py,SerialController 从无寻址的自定义 55C8 协议改为 MODBUS-RTU(设备ID+CRC16),组网总线上可按板号(1/2)单独控制。现有设备 默认板1,relay.helper/测试 API 不变,向后兼容。 - serial_controller.ts: crc16 + MODBUS 帧(功能05/0F/01)、deviceId 参数、ping 探测 - relay.config.ts: 新增 board 字段 + resolveRelayBoard,resolveRelayAction 返回 board - relay.helper.ts: 各动作按 config 路由板号 - relay.ts / press-raw.ts: --board / BOARD env 支持 不含 package-lock.json 与本地 relay_control.py(机器相关,避免影响其他电脑)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4b76e5de17
commit
7c3d4a0a7e
|
|
@ -25,39 +25,41 @@ export interface RelayAction {
|
||||||
export interface RelayDeviceConfig {
|
export interface RelayDeviceConfig {
|
||||||
buttons: Record<string, ChannelSpec>; // 按键名 → 通道(可按平台)
|
buttons: Record<string, ChannelSpec>; // 按键名 → 通道(可按平台)
|
||||||
actions?: Record<string, RelayAction>;
|
actions?: Record<string, RelayAction>;
|
||||||
|
/** 该设备接在哪块继电器板(MODBUS 设备 ID/板号)。缺省=1。现有接线全在 1 号板,新增板上的设备标 board: 2。 */
|
||||||
|
board?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELAY_MAP: Record<string, RelayDeviceConfig> = {
|
export const RELAY_MAP: Record<string, RelayDeviceConfig> = {
|
||||||
meter: {
|
meter: {
|
||||||
buttons: { main: { android: 32 /*, ios: <填iOS通道> */ } },
|
buttons: { main: { android: 32 , ios: 33 } },
|
||||||
actions: {
|
actions: {
|
||||||
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键进入添加模式' },
|
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键进入添加模式' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
remote: {
|
remote: {
|
||||||
buttons: {
|
buttons: {
|
||||||
convex: { android: 26 /*, ios: <填> */ }, // 凸键
|
convex: { android: 26 , ios: 42 }, // 凸键
|
||||||
concave: { android: 25 /*, ios: <填> */ }, // 凹键
|
concave: { android: 25 , ios: 43 }, // 凹键
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
pairing: { buttons: ['convex', 'concave'], holdMs: 2000, desc: '同时按凸键+凹键 2s 进入添加模式(切换式:按一次进、再按退,勿重按)' },
|
pairing: { buttons: ['convex', 'concave'], holdMs: 2000, desc: '同时按凸键+凹键 2s 进入添加模式(切换式:按一次进、再按退,勿重按)' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
outdoor_meter: {
|
outdoor_meter: {
|
||||||
buttons: { main: { android: 28 /*, ios: <填> */ } },
|
buttons: { main: { android: 28 , ios: 38 } },
|
||||||
actions: {
|
actions: {
|
||||||
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键 2s 进入添加模式' },
|
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键 2s 进入添加模式' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
motion: {
|
motion: {
|
||||||
buttons: { main: { android: 29 /*, ios: <填> */ } }, // Motion Sensor 配对键(电池仓内按键)
|
buttons: { main: { android: 29 , ios: 34 } }, // Motion Sensor 配对键(电池仓内按键)
|
||||||
actions: {
|
actions: {
|
||||||
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键 2s 进入添加模式' },
|
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键 2s 进入添加模式' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
curtain3: {
|
curtain3: {
|
||||||
buttons: {
|
buttons: {
|
||||||
power: { android: 27 /*, ios: <填> */ }, // 开关按钮
|
power: { android: 27 , ios: 46}, // 开关按钮
|
||||||
track: { android: 38 /*, ios: <填> */ }, // 控制轨道按钮(控制用,不参与配对)
|
track: { android: 38 /*, ios: <填> */ }, // 控制轨道按钮(控制用,不参与配对)
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
|
@ -65,13 +67,13 @@ export const RELAY_MAP: Record<string, RelayDeviceConfig> = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
curtain: {
|
curtain: {
|
||||||
buttons: { power: { android: 24 /*, ios: <填> */ } }, // 开关按钮
|
buttons: { power: { android: 24 , ios: 63 } }, // 开关按钮
|
||||||
actions: {
|
actions: {
|
||||||
pairing: { buttons: ['power'], holdMs: 2000, desc: '按住开关按钮 2s 进入添加模式' },
|
pairing: { buttons: ['power'], holdMs: 2000, desc: '按住开关按钮 2s 进入添加模式' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
hub: {
|
hub: {
|
||||||
buttons: { main: { android: 23 /*, ios: <填> */ } }, // Hub Mini 主键
|
buttons: { main: { android: 23 , ios: 37} }, // Hub Mini 主键
|
||||||
actions: {
|
actions: {
|
||||||
pairing: { buttons: ['main'], holdMs: 4000, desc: '按住主键 4s 进入添加模式(Hub Mini;指引页写3s,实测需4s)' },
|
pairing: { buttons: ['main'], holdMs: 4000, desc: '按住主键 4s 进入添加模式(Hub Mini;指引页写3s,实测需4s)' },
|
||||||
},
|
},
|
||||||
|
|
@ -87,8 +89,8 @@ export const RELAY_MAP: Record<string, RelayDeviceConfig> = {
|
||||||
},
|
},
|
||||||
hub_plus: {
|
hub_plus: {
|
||||||
buttons: {
|
buttons: {
|
||||||
a: { android: 14 /*, ios: <填> */ }, // Hub Plus 配对键1(ch14,与 K20+/S10 共用,物理同时只接一个)
|
a: { android: 14 , ios: 47 }, // Hub Plus 配对键1(ch14,与 K20+/S10 共用,物理同时只接一个)
|
||||||
b: { android: 15 /*, ios: <填> */ }, // Hub Plus 配对键2(ch15)
|
b: { android: 15 , ios: 48 }, // Hub Plus 配对键2(ch15)
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
pairing: { buttons: ['a', 'b'], holdMs: 4000, desc: '同时长按 ch14+15 4s 进入添加模式(Hub Plus,WiFi;时长待实测)' },
|
pairing: { buttons: ['a', 'b'], holdMs: 4000, desc: '同时长按 ch14+15 4s 进入添加模式(Hub Plus,WiFi;时长待实测)' },
|
||||||
|
|
@ -102,7 +104,7 @@ export const RELAY_MAP: Record<string, RelayDeviceConfig> = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
water: {
|
water: {
|
||||||
buttons: { main: { android: 22 /*, ios: <填> */ } }, // Water Detector(水浸)配对键
|
buttons: { main: { android: 22 , ios: 40 } }, // Water Detector(水浸)配对键
|
||||||
actions: {
|
actions: {
|
||||||
// ⚠️ 配对时长待实测;先按 2s 试
|
// ⚠️ 配对时长待实测;先按 2s 试
|
||||||
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键进入添加模式(水浸,待实测确认时长)' },
|
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键进入添加模式(水浸,待实测确认时长)' },
|
||||||
|
|
@ -125,20 +127,20 @@ export const RELAY_MAP: Record<string, RelayDeviceConfig> = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
contact: {
|
contact: {
|
||||||
buttons: { main: { android: 47 /*, ios: <填> */ } }, // Contact Sensor 配对键(ch47,2026-06 由 44 改;与 plug_mini_eu 同口,同时只接一个)
|
buttons: { main: { android: 47 , ios: 45 } }, // Contact Sensor 配对键(ch47,2026-06 由 44 改;与 plug_mini_eu 同口,同时只接一个)
|
||||||
actions: {
|
actions: {
|
||||||
// 实测:长按 6s 进配对(2s/4s 不够,2026-06 确认)
|
// 实测:长按 6s 进配对(2s/4s 不够,2026-06 确认)
|
||||||
pairing: { buttons: ['main'], holdMs: 6000, desc: '长按配对键 6s 进入添加模式(Contact Sensor)' },
|
pairing: { buttons: ['main'], holdMs: 6000, desc: '长按配对键 6s 进入添加模式(Contact Sensor)' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
meter_plus: {
|
meter_plus: {
|
||||||
buttons: { main: { android: 31 /*, ios: <填> */ } }, // Meter Plus 主键
|
buttons: { main: { android: 31 , ios: 44 } }, // Meter Plus 主键
|
||||||
actions: {
|
actions: {
|
||||||
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键 2s 进入添加模式(Meter Plus)' },
|
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键 2s 进入添加模式(Meter Plus)' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
find_card: {
|
find_card: {
|
||||||
buttons: { main: { android: 30 /*, ios: <填> */ } }, // Wallet Finder Card 配对键
|
buttons: { main: { android: 30 , ios: 64 } }, // Wallet Finder Card 配对键
|
||||||
actions: {
|
actions: {
|
||||||
// ⚠️ 配对时长待实测
|
// ⚠️ 配对时长待实测
|
||||||
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键进入添加模式(Wallet Finder Card,待实测确认时长)' },
|
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键进入添加模式(Wallet Finder Card,待实测确认时长)' },
|
||||||
|
|
@ -157,25 +159,25 @@ export const RELAY_MAP: Record<string, RelayDeviceConfig> = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
safety_alarm: {
|
safety_alarm: {
|
||||||
buttons: { main: { android: 3 /*, ios: <填> */ } }, // Safety Alarm 配对键(ch3,BLE 无WiFi,流程近 Find Card)
|
buttons: { main: { android: 3 , ios:49 } }, // Safety Alarm 配对键(ch3,BLE 无WiFi,流程近 Find Card)
|
||||||
actions: {
|
actions: {
|
||||||
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键 2s 进入添加模式(Safety Alarm,BLE无WiFi;选品类后 Disclaimers→Next 再 Connect Device)' },
|
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键 2s 进入添加模式(Safety Alarm,BLE无WiFi;选品类后 Disclaimers→Next 再 Connect Device)' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
meter_pro: {
|
meter_pro: {
|
||||||
buttons: { main: { android: 4 /*, ios: <填> */ } }, // Meter Pro (CO2) 配对键(ch4,纯BLE,与 meter 同流程、入口品类名不同)
|
buttons: { main: { android: 4 , ios: 36 } }, // Meter Pro (CO2) 配对键(ch4,纯BLE,与 meter 同流程、入口品类名不同)
|
||||||
actions: {
|
actions: {
|
||||||
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键 2s 进入添加模式(Meter Pro CO2)' },
|
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住主键 2s 进入添加模式(Meter Pro CO2)' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
presence_sensor: {
|
presence_sensor: {
|
||||||
buttons: { main: { android: 5 /*, ios: <填> */ } }, // Presence Sensor 配对键(ch5,与 Motion Sensor 同品类组/同流程,入口名不同)
|
buttons: { main: { android: 5 , ios: 35 } }, // Presence Sensor 配对键(ch5,与 Motion Sensor 同品类组/同流程,入口名不同)
|
||||||
actions: {
|
actions: {
|
||||||
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键 2s 进入添加模式(Presence Sensor)' },
|
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键 2s 进入添加模式(Presence Sensor)' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
curtain3_2025: {
|
curtain3_2025: {
|
||||||
buttons: { power: { android: 6 /*, ios: <填> */ } }, // Curtain 3 2025 开关键(ch6,入口品类=Curtain;校准流程待实测 curtain/curtain3)
|
buttons: { power: { android: 6 , ios: 39 } }, // Curtain 3 2025 开关键(ch6,入口品类=Curtain;校准流程待实测 curtain/curtain3)
|
||||||
actions: {
|
actions: {
|
||||||
pairing: { buttons: ['power'], holdMs: 2000, desc: '按住开关键 2s 进入添加模式(Curtain 3 2025)' },
|
pairing: { buttons: ['power'], holdMs: 2000, desc: '按住开关键 2s 进入添加模式(Curtain 3 2025)' },
|
||||||
},
|
},
|
||||||
|
|
@ -201,7 +203,7 @@ export const RELAY_MAP: Record<string, RelayDeviceConfig> = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
strip_light: {
|
strip_light: {
|
||||||
buttons: { main: { android: 33 /*, ios: <填> */ } }, // Strip Light(灯带)配对键(ch33,灯类,WiFi 产品)
|
buttons: { main: { android: 33 , ios: 41 } }, // Strip Light(灯带)配对键(ch33,灯类,WiFi 产品)
|
||||||
actions: {
|
actions: {
|
||||||
// 长按 2s 进配对(灯带,不同于 color bulb 的两次点按)。
|
// 长按 2s 进配对(灯带,不同于 color bulb 的两次点按)。
|
||||||
pairing: { buttons: ['main'], holdMs: 2000, desc: '长按配对键 2s 进入添加模式(Strip Light 灯带,WiFi)' },
|
pairing: { buttons: ['main'], holdMs: 2000, desc: '长按配对键 2s 进入添加模式(Strip Light 灯带,WiFi)' },
|
||||||
|
|
@ -217,7 +219,7 @@ export const RELAY_MAP: Record<string, RelayDeviceConfig> = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
outdoor_ptc: {
|
outdoor_ptc: {
|
||||||
buttons: { main: { android: 36 /*, ios: <填> */ } }, // Outdoor PTC 配对键(ch36;与 doorbell.talk 同口,物理同时只接一个)
|
buttons: { main: { android: 36 , ios: 50 } }, // Outdoor PTC 配对键(ch36;与 doorbell.talk 同口,物理同时只接一个)
|
||||||
actions: {
|
actions: {
|
||||||
// ⚠️ 配对时长待实测;先按 2s 试(流程参考水浸 Water Detector:按键进配对 + WiFi 配网)
|
// ⚠️ 配对时长待实测;先按 2s 试(流程参考水浸 Water Detector:按键进配对 + WiFi 配网)
|
||||||
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键 2s 进入添加模式(Outdoor PTC,入口品类 Outdoor Pan/Tilt Cam 3K;待实测确认时长)' },
|
pairing: { buttons: ['main'], holdMs: 2000, desc: '按住配对键 2s 进入添加模式(Outdoor PTC,入口品类 Outdoor Pan/Tilt Cam 3K;待实测确认时长)' },
|
||||||
|
|
@ -327,13 +329,13 @@ export const RELAY_MAP: Record<string, RelayDeviceConfig> = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
humidifier2: {
|
humidifier2: {
|
||||||
buttons: { main: { android: 63 /*, ios: <填> */ } }, // Evaporative Humidifier(口头"加湿器2")配对键(ch63,带 WiFi 模块)
|
buttons: { main: { android: 63 , ios: 62 } }, // Evaporative Humidifier(口头"加湿器2")配对键(ch63,带 WiFi 模块)
|
||||||
actions: {
|
actions: {
|
||||||
pairing: { buttons: ['main'], holdMs: 2000, desc: '长按配对键 2s 进入添加模式(Evaporative Humidifier,WiFi,实测2s可用)' },
|
pairing: { buttons: ['main'], holdMs: 2000, desc: '长按配对键 2s 进入添加模式(Evaporative Humidifier,WiFi,实测2s可用)' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
air_purifier: {
|
air_purifier: {
|
||||||
buttons: { main: { android: 62 /*, ios: <填> */ } }, // 空气净化器(Air Purifier)配对键(ch62,带 WiFi 模块;流程同加湿器2)
|
buttons: { main: { android: 62 , ios: 32 } }, // 空气净化器(Air Purifier)配对键(ch62,带 WiFi 模块;流程同加湿器2)
|
||||||
actions: {
|
actions: {
|
||||||
pairing: { buttons: ['main'], holdMs: 2000, desc: '长按配对键 2s 进入添加模式(Air Purifier,WiFi;流程同 Evaporative Humidifier)' },
|
pairing: { buttons: ['main'], holdMs: 2000, desc: '长按配对键 2s 进入添加模式(Air Purifier,WiFi;流程同 Evaporative Humidifier)' },
|
||||||
},
|
},
|
||||||
|
|
@ -355,11 +357,16 @@ function channelOf(device: string, button: string): number {
|
||||||
return ch;
|
return ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取设备某动作要触发的通道列表 + 时长(按当前平台解析通道;env 可覆盖时长)。 */
|
/** 取设备所在板号(MODBUS 设备 ID);缺省 1(现有接线全在 1 号板)。 */
|
||||||
|
export function resolveRelayBoard(device: string): number {
|
||||||
|
return RELAY_MAP[device]?.board ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取设备某动作要触发的通道列表 + 时长 + 板号(按当前平台解析通道;env 可覆盖时长)。 */
|
||||||
export function resolveRelayAction(
|
export function resolveRelayAction(
|
||||||
device: string,
|
device: string,
|
||||||
action: string
|
action: string
|
||||||
): { channels: number[]; holdMs: number; desc?: string } {
|
): { channels: number[]; holdMs: number; desc?: string; board: number } {
|
||||||
const dev = RELAY_MAP[device];
|
const dev = RELAY_MAP[device];
|
||||||
if (!dev) throw new Error(`relay 配置无设备 '${device}'(config/relay.config.ts)`);
|
if (!dev) throw new Error(`relay 配置无设备 '${device}'(config/relay.config.ts)`);
|
||||||
const act = dev.actions?.[action];
|
const act = dev.actions?.[action];
|
||||||
|
|
@ -367,7 +374,7 @@ export function resolveRelayAction(
|
||||||
const channels = act.buttons.map((b) => channelOf(device, b));
|
const channels = act.buttons.map((b) => channelOf(device, b));
|
||||||
const envKey = `RELAY_HOLD_MS_${device.toUpperCase()}_${action.toUpperCase()}`;
|
const envKey = `RELAY_HOLD_MS_${device.toUpperCase()}_${action.toUpperCase()}`;
|
||||||
const holdMs = process.env[envKey] ? parseInt(process.env[envKey] as string, 10) : act.holdMs;
|
const holdMs = process.env[envKey] ? parseInt(process.env[envKey] as string, 10) : act.holdMs;
|
||||||
return { channels, holdMs, desc: act.desc };
|
return { channels, holdMs, desc: act.desc, board: resolveRelayBoard(device) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取设备某按键在当前平台的通道号。 */
|
/** 取设备某按键在当前平台的通道号。 */
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ import { SerialController } from '../utils/common/serial_controller';
|
||||||
(async () => {
|
(async () => {
|
||||||
const chs = (process.env.CHS || '50,51').split(',').map(Number);
|
const chs = (process.env.CHS || '50,51').split(',').map(Number);
|
||||||
const ms = Number(process.env.MS || 5000);
|
const ms = Number(process.env.MS || 5000);
|
||||||
const c = new SerialController();
|
const board = process.env.BOARD ? Number(process.env.BOARD) : undefined;
|
||||||
|
const c = new SerialController(board ? { deviceId: board } : {});
|
||||||
await c.open();
|
await c.open();
|
||||||
try { await c.pulseChannels(chs, ms); console.log(`已脉冲通道[${chs.join(',')}] ${ms}ms`); }
|
try { await c.pulseChannels(chs, ms, board); console.log(`已脉冲 板${board ?? c.defaultDeviceId} 通道[${chs.join(',')}] ${ms}ms`); }
|
||||||
finally { await c.close(); }
|
finally { await c.close(); }
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
/**
|
/**
|
||||||
* 继电器命令行调试工具 —— 参数化触发按键/配对/原始命令,方便接线调试。
|
* 继电器命令行调试工具 —— 参数化触发按键/配对/原始命令,方便接线调试。
|
||||||
*
|
*
|
||||||
|
* 多板:--board N 指定 MODBUS 板号(默认 1)。pair/press 不传时按 config 的 board 字段自动路由。
|
||||||
|
*
|
||||||
* 用法(端口/波特率走 .env 的 SERIAL_PORT/SERIAL_BAUD):
|
* 用法(端口/波特率走 .env 的 SERIAL_PORT/SERIAL_BAUD):
|
||||||
* npx ts-node scripts/relay.ts probe # 查版本(只读握手)
|
* npx ts-node scripts/relay.ts probe --board 1 # 在线探测指定板(读线圈)
|
||||||
* npx ts-node scripts/relay.ts status # 读 64 路状态
|
* npx ts-node scripts/relay.ts status --board 2 # 读 64 路线圈状态
|
||||||
* npx ts-node scripts/relay.ts pair remote # 进配对(按 config 默认)
|
* npx ts-node scripts/relay.ts pair remote # 进配对(板号按 config)
|
||||||
* npx ts-node scripts/relay.ts pair meter --hold 2500 # 进配对,覆盖时长
|
* npx ts-node scripts/relay.ts pair meter --hold 2500 # 进配对,覆盖时长
|
||||||
* npx ts-node scripts/relay.ts press remote convex --hold 800 # 点按命名键
|
* npx ts-node scripts/relay.ts press remote convex --hold 800 # 点按命名键
|
||||||
* npx ts-node scripts/relay.ts pulse 34,35 --hold 3000 # 同时脉冲指定通道
|
* npx ts-node scripts/relay.ts pulse 34,35 --hold 3000 --board 1 # 同时脉冲指定通道
|
||||||
* npx ts-node scripts/relay.ts on 34 / off 34 # 单路通/断
|
* npx ts-node scripts/relay.ts on 34 --board 2 / off 34 # 单路通/断
|
||||||
* npx ts-node scripts/relay.ts raw 55C8210155 # 发原始 16 进制帧
|
* npx ts-node scripts/relay.ts raw 010500050000 # 发原始 16 进制帧(自带 CRC 时勿重复)
|
||||||
*/
|
*/
|
||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -40,63 +42,64 @@ function positionals(): string[] {
|
||||||
async function main() {
|
async function main() {
|
||||||
const [cmd, a, b] = positionals();
|
const [cmd, a, b] = positionals();
|
||||||
const hold = flag('hold') ? parseInt(flag('hold') as string, 10) : undefined;
|
const hold = flag('hold') ? parseInt(flag('hold') as string, 10) : undefined;
|
||||||
|
const board = flag('board') ? parseInt(flag('board') as string, 10) : undefined;
|
||||||
|
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case 'probe': {
|
case 'probe': {
|
||||||
const c = new SerialController();
|
const c = new SerialController(board ? { deviceId: board } : {});
|
||||||
await c.open();
|
await c.open();
|
||||||
console.log(await c.queryVersion());
|
console.log(await c.ping(board));
|
||||||
await c.close();
|
await c.close();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'status': {
|
case 'status': {
|
||||||
const c = new SerialController();
|
const c = new SerialController(board ? { deviceId: board } : {});
|
||||||
await c.open();
|
await c.open();
|
||||||
console.log('status:', (await c.readStatus()).toString('hex').toUpperCase());
|
console.log('status:', (await c.readStatus(board)).toString('hex').toUpperCase());
|
||||||
await c.close();
|
await c.close();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'pair': {
|
case 'pair': {
|
||||||
if (!a) throw new Error('用法: pair <device> [--hold ms]');
|
if (!a) throw new Error('用法: pair <device> [--hold ms] [--board N]');
|
||||||
await triggerRelayAction(a, 'pairing', hold ? { holdMs: hold } : {});
|
await triggerRelayAction(a, 'pairing', { ...(hold ? { holdMs: hold } : {}), ...(board ? { deviceId: board } : {}) });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'press': {
|
case 'press': {
|
||||||
if (!a || !b) throw new Error('用法: press <device> <button> [--hold ms]');
|
if (!a || !b) throw new Error('用法: press <device> <button> [--hold ms] [--board N]');
|
||||||
await pressButton(a, b, hold ?? 500);
|
await pressButton(a, b, hold ?? 500, board ? { deviceId: board } : {});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'pulse': {
|
case 'pulse': {
|
||||||
if (!a) throw new Error('用法: pulse <ch1,ch2,...> [--hold ms]');
|
if (!a) throw new Error('用法: pulse <ch1,ch2,...> [--hold ms] [--board N]');
|
||||||
const chs = a.split(',').map((s) => parseInt(s, 10));
|
const chs = a.split(',').map((s) => parseInt(s, 10));
|
||||||
const c = new SerialController();
|
const c = new SerialController(board ? { deviceId: board } : {});
|
||||||
await c.open();
|
await c.open();
|
||||||
await c.pulseChannels(chs, hold ?? 500);
|
await c.pulseChannels(chs, hold ?? 500, board);
|
||||||
console.log(`脉冲通道[${chs.join(',')}] ${hold ?? 500}ms 完成`);
|
console.log(`脉冲 板${board ?? c.defaultDeviceId} 通道[${chs.join(',')}] ${hold ?? 500}ms 完成`);
|
||||||
await c.close();
|
await c.close();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'on':
|
case 'on':
|
||||||
case 'off': {
|
case 'off': {
|
||||||
if (!a) throw new Error(`用法: ${cmd} <ch>`);
|
if (!a) throw new Error(`用法: ${cmd} <ch> [--board N]`);
|
||||||
const c = new SerialController();
|
const c = new SerialController(board ? { deviceId: board } : {});
|
||||||
await c.open();
|
await c.open();
|
||||||
const resp = cmd === 'on' ? await c.relayOn(parseInt(a, 10)) : await c.relayOff(parseInt(a, 10));
|
const resp = cmd === 'on' ? await c.relayOn(parseInt(a, 10), board) : await c.relayOff(parseInt(a, 10), board);
|
||||||
console.log(`${cmd} ${a}:`, resp.toString('hex').toUpperCase());
|
console.log(`${cmd} ${a} (板${board ?? c.defaultDeviceId}):`, resp.toString('hex').toUpperCase());
|
||||||
await c.close();
|
await c.close();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'raw': {
|
case 'raw': {
|
||||||
if (!a) throw new Error('用法: raw <hex,如 55C8210155>');
|
if (!a) throw new Error('用法: raw <hex,如 010500050000>');
|
||||||
const bytes = (a.match(/.{1,2}/g) || []).map((h) => parseInt(h, 16));
|
const bytes = (a.match(/.{1,2}/g) || []).map((h) => parseInt(h, 16));
|
||||||
const c = new SerialController();
|
const c = new SerialController(board ? { deviceId: board } : {});
|
||||||
await c.open();
|
await c.open();
|
||||||
console.log('resp:', (await c.send(bytes)).toString('hex').toUpperCase());
|
console.log('resp:', (await c.send(bytes)).toString('hex').toUpperCase());
|
||||||
await c.close();
|
await c.close();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
console.log('命令: probe | status | pair <dev> | press <dev> <btn> | pulse <chs> | on <ch> | off <ch> | raw <hex> [--hold ms]');
|
console.log('命令: probe | status | pair <dev> | press <dev> <btn> | pulse <chs> | on <ch> | off <ch> | raw <hex> [--hold ms] [--board N]');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
* 默认每次自建并关闭控制器(一次性动作);要在一个会话里连续操作,传 opts.controller 复用。
|
* 默认每次自建并关闭控制器(一次性动作);要在一个会话里连续操作,传 opts.controller 复用。
|
||||||
*/
|
*/
|
||||||
import { SerialController, SerialOptions } from './serial_controller';
|
import { SerialController, SerialOptions } from './serial_controller';
|
||||||
import { resolveRelayAction, resolveRelayButton } from '../../config/relay.config';
|
import { resolveRelayAction, resolveRelayButton, resolveRelayBoard } from '../../config/relay.config';
|
||||||
|
|
||||||
export interface RelayActionOptions extends SerialOptions {
|
export interface RelayActionOptions extends SerialOptions {
|
||||||
/** 覆盖配置里的按住时长(毫秒)。 */
|
/** 覆盖配置里的按住时长(毫秒)。 */
|
||||||
|
|
@ -42,12 +42,13 @@ export async function triggerRelayAction(
|
||||||
device: string,
|
device: string,
|
||||||
action = 'pairing',
|
action = 'pairing',
|
||||||
opts: RelayActionOptions = {}
|
opts: RelayActionOptions = {}
|
||||||
): Promise<{ channels: number[]; holdMs: number; desc?: string }> {
|
): Promise<{ channels: number[]; holdMs: number; desc?: string; board: number }> {
|
||||||
const resolved = resolveRelayAction(device, action);
|
const resolved = resolveRelayAction(device, action);
|
||||||
const holdMs = opts.holdMs ?? resolved.holdMs;
|
const holdMs = opts.holdMs ?? resolved.holdMs;
|
||||||
await withController(opts, (c) => c.pulseChannels(resolved.channels, holdMs));
|
const board = opts.deviceId ?? resolved.board;
|
||||||
console.log(`relay: ${device}.${action} → 通道[${resolved.channels.join(',')}] 按住 ${holdMs}ms${resolved.desc ? ' (' + resolved.desc + ')' : ''}`);
|
await withController(opts, (c) => c.pulseChannels(resolved.channels, holdMs, board));
|
||||||
return { ...resolved, holdMs };
|
console.log(`relay: ${device}.${action} → 板${board} 通道[${resolved.channels.join(',')}] 按住 ${holdMs}ms${resolved.desc ? ' (' + resolved.desc + ')' : ''}`);
|
||||||
|
return { ...resolved, holdMs, board };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 让设备进入添加/配对模式(= triggerRelayAction(device,'pairing'))。 */
|
/** 让设备进入添加/配对模式(= triggerRelayAction(device,'pairing'))。 */
|
||||||
|
|
@ -63,8 +64,9 @@ export async function pressButton(
|
||||||
opts: RelayActionOptions = {}
|
opts: RelayActionOptions = {}
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const ch = resolveRelayButton(device, button);
|
const ch = resolveRelayButton(device, button);
|
||||||
await withController(opts, (c) => c.pulse(ch, ms));
|
const board = opts.deviceId ?? resolveRelayBoard(device);
|
||||||
console.log(`relay: 点按 ${device}.${button} → 通道 ${ch} ${ms}ms`);
|
await withController(opts, (c) => c.pulse(ch, ms, board));
|
||||||
|
console.log(`relay: 点按 ${device}.${button} → 板${board} 通道 ${ch} ${ms}ms`);
|
||||||
return ch;
|
return ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,8 +78,9 @@ export async function pressButtonsTogether(
|
||||||
opts: RelayActionOptions = {}
|
opts: RelayActionOptions = {}
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
const channels = buttons.map((b) => resolveRelayButton(device, b));
|
const channels = buttons.map((b) => resolveRelayButton(device, b));
|
||||||
await withController(opts, (c) => c.pulseChannels(channels, ms));
|
const board = opts.deviceId ?? resolveRelayBoard(device);
|
||||||
console.log(`relay: 同时按 ${device}.[${buttons.join('+')}] → 通道[${channels.join(',')}] ${ms}ms`);
|
await withController(opts, (c) => c.pulseChannels(channels, ms, board));
|
||||||
|
console.log(`relay: 同时按 ${device}.[${buttons.join('+')}] → 板${board} 通道[${channels.join(',')}] ${ms}ms`);
|
||||||
return channels;
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,11 +92,12 @@ export async function powerCycle(
|
||||||
opts: RelayActionOptions = {}
|
opts: RelayActionOptions = {}
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const ch = resolveRelayButton(device, button);
|
const ch = resolveRelayButton(device, button);
|
||||||
|
const board = opts.deviceId ?? resolveRelayBoard(device);
|
||||||
await withController(opts, async (c) => {
|
await withController(opts, async (c) => {
|
||||||
await c.relayOff(ch);
|
await c.relayOff(ch, board);
|
||||||
await new Promise((r) => setTimeout(r, offMs));
|
await new Promise((r) => setTimeout(r, offMs));
|
||||||
await c.relayOn(ch);
|
await c.relayOn(ch, board);
|
||||||
});
|
});
|
||||||
console.log(`relay: 电源循环 ${device}.${button} → 通道 ${ch}(断电 ${offMs}ms 后通电)`);
|
console.log(`relay: 电源循环 ${device}.${button} → 板${board} 通道 ${ch}(断电 ${offMs}ms 后通电)`);
|
||||||
return ch;
|
return ch;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,50 @@
|
||||||
/**
|
/**
|
||||||
* 64 路继电器板串口控制器 —— 昆明创泽 KMCZE-I64O64-V2.5(RS232/RS485,自定义 16 进制协议)。
|
* 64 路继电器板串口控制器 —— 昆明创泽 KMCZE-I64O64-V2.5(RS232/RS485,MODBUS-RTU 协议)。
|
||||||
*
|
*
|
||||||
* 用途:设备「添加」流程里,用继电器通断模拟按物理按键(配对/复位键)、给被测设备上下电。
|
* 用途:设备「添加」流程里,用继电器通断模拟按物理按键(配对/复位键)、给被测设备上下电。
|
||||||
*
|
*
|
||||||
* 串口参数(规格书):无校验 / 1 停止位 / 无流控;波特率 RS232 默认 115200、RS485 默认 9600。
|
* **多板组网**:总线上可挂多块板,用 MODBUS 设备 ID 区分(本项目板号 1、2)。每帧首字节即设备 ID,
|
||||||
|
* 只有该 ID 的板会响应,因此能在共用串口/总线上单独控制某一块板。设备 ID 走 opts.deviceId
|
||||||
|
* 或 env SERIAL_DEVICE_ID,默认 1;每个帧方法也可临时传 deviceId 覆盖。
|
||||||
|
*
|
||||||
|
* 串口参数(规格书):无校验 / 1 停止位 / 无流控;波特率 115200(MODBUS-RTU 默认)。
|
||||||
* 端口与波特率走 env,绝不写死:SERIAL_PORT / SERIAL_BAUD。
|
* 端口与波特率走 env,绝不写死:SERIAL_PORT / SERIAL_BAUD。
|
||||||
*
|
*
|
||||||
* 帧格式(16 进制):
|
* 帧格式(MODBUS-RTU,id=设备ID,末尾小端 CRC16;线圈地址=通道号,1-based):
|
||||||
* 控制单路: 55 C8 <ch> <01|00> 55 (ch=1..64 的十六进制;01 通 / 00 断)
|
* 控制单路: id 05 00 <ch> <FF00 通 | 0000 断> + CRC (功能05 写单线圈)
|
||||||
* 控制全 64: 55 C8 41 <8 字节状态> 55
|
* 控制全 64: id 0F 00 01 00 40 08 <8字节线圈位> + CRC (功能0F 写多线圈,起始线圈1,64路)
|
||||||
* 读状态: 55 C7 01 00 55 → 1B DB <8字节输入><8字节输出> 0C
|
* 读状态: id 01 00 01 00 40 + CRC (功能01 读64线圈)
|
||||||
* 查版本: 55 D3 D3 00 55 → AB 2D AA 20 ...(只读,安全)
|
* 在线探测: 同读状态;回包首字节=id 且功能码非异常(0x81)即在线
|
||||||
* 延时通断: 55 C9 <ch> <01断|02通> <2字节×0.1s> 55
|
*
|
||||||
|
* 参照同目录 relay_control.py(MODBUS-RTU 参考实现)。
|
||||||
*/
|
*/
|
||||||
import { SerialPort } from 'serialport';
|
import { SerialPort } from 'serialport';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const DEFAULT_PORT = '/dev/cu.usbserial-A50285BI';
|
const DEFAULT_PORT = '/dev/cu.usbserial-A50285BI';
|
||||||
// 按钮点击寿命统计:按「板子(串口序列号)→通道」累加,持久化。继电器寿命约 20 万次。
|
const DEFAULT_DEVICE_ID = 1; // 默认板号(MODBUS 设备 ID)
|
||||||
|
// 按钮点击寿命统计:按「板号(MODBUS 设备 ID)→通道」累加,持久化。继电器寿命约 20 万次。
|
||||||
const PRESS_COUNT_FILE = path.resolve(__dirname, '../../test-plan/relay-press-counts.json');
|
const PRESS_COUNT_FILE = path.resolve(__dirname, '../../test-plan/relay-press-counts.json');
|
||||||
const RELAY_LIFESPAN = 200000;
|
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 {
|
export interface SerialOptions {
|
||||||
port?: string;
|
port?: string;
|
||||||
baudRate?: number;
|
baudRate?: number;
|
||||||
|
/** 默认目标板号(MODBUS 设备 ID);各帧方法可临时覆盖。默认 1。 */
|
||||||
|
deviceId?: number;
|
||||||
/** 发送后等待应答的毫秒数(默认 300ms) */
|
/** 发送后等待应答的毫秒数(默认 300ms) */
|
||||||
responseTimeout?: number;
|
responseTimeout?: number;
|
||||||
}
|
}
|
||||||
|
|
@ -33,11 +53,13 @@ export class SerialController {
|
||||||
private sp: SerialPort | null = null;
|
private sp: SerialPort | null = null;
|
||||||
private readonly path: string;
|
private readonly path: string;
|
||||||
private readonly baudRate: number;
|
private readonly baudRate: number;
|
||||||
|
private readonly deviceId: number;
|
||||||
private readonly responseTimeout: number;
|
private readonly responseTimeout: number;
|
||||||
|
|
||||||
constructor(opts: SerialOptions = {}) {
|
constructor(opts: SerialOptions = {}) {
|
||||||
this.path = opts.port || process.env.SERIAL_PORT || DEFAULT_PORT;
|
this.path = opts.port || process.env.SERIAL_PORT || DEFAULT_PORT;
|
||||||
this.baudRate = opts.baudRate || parseInt(process.env.SERIAL_BAUD || '115200', 10);
|
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;
|
this.responseTimeout = opts.responseTimeout ?? 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,19 +67,17 @@ export class SerialController {
|
||||||
return this.path;
|
return this.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 板子标识:从串口路径取序列号(如 usbserial-A50285BI → A50285BI),区分多块板;env SERIAL_BOARD_ID 可覆盖。 */
|
/** 该控制器默认目标板号(MODBUS 设备 ID)。 */
|
||||||
get boardId(): string {
|
get defaultDeviceId(): number {
|
||||||
if (process.env.SERIAL_BOARD_ID) return process.env.SERIAL_BOARD_ID;
|
return this.deviceId;
|
||||||
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。 */
|
/** 累加某板各通道的按钮点击次数(继电器寿命约 20 万次),持久化到 test-plan/relay-press-counts.json。 */
|
||||||
private countPress(channels: number[]): void {
|
private countPress(channels: number[], deviceId: number): void {
|
||||||
try {
|
try {
|
||||||
let data: Record<string, Record<string, number>> = {};
|
let data: Record<string, Record<string, number>> = {};
|
||||||
if (fs.existsSync(PRESS_COUNT_FILE)) data = JSON.parse(fs.readFileSync(PRESS_COUNT_FILE, 'utf-8'));
|
if (fs.existsSync(PRESS_COUNT_FILE)) data = JSON.parse(fs.readFileSync(PRESS_COUNT_FILE, 'utf-8'));
|
||||||
const board = this.boardId;
|
const board = `板${deviceId}`;
|
||||||
data[board] = data[board] || {};
|
data[board] = data[board] || {};
|
||||||
for (const ch of channels) data[board][ch] = (data[board][ch] || 0) + 1;
|
for (const ch of channels) data[board][ch] = (data[board][ch] || 0) + 1;
|
||||||
fs.mkdirSync(path.dirname(PRESS_COUNT_FILE), { recursive: true });
|
fs.mkdirSync(path.dirname(PRESS_COUNT_FILE), { recursive: true });
|
||||||
|
|
@ -100,94 +120,117 @@ export class SerialController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 组一帧 MODBUS-RTU(自动追加小端 CRC16)。 */
|
||||||
|
private frame(bytes: number[]): Buffer {
|
||||||
|
return Buffer.concat([Buffer.from(bytes), crc16(bytes)]);
|
||||||
|
}
|
||||||
|
|
||||||
private assertChannel(ch: number): void {
|
private assertChannel(ch: number): void {
|
||||||
if (!Number.isInteger(ch) || ch < 1 || ch > 64) throw new Error(`通道号必须是 1..64,收到 ${ch}`);
|
if (!Number.isInteger(ch) || ch < 1 || ch > 64) throw new Error(`通道号必须是 1..64,收到 ${ch}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 第 ch 路接通(ch: 1..64) */
|
private assertDeviceId(id: number): void {
|
||||||
relayOn(ch: number): Promise<Buffer> {
|
if (!Number.isInteger(id) || id < 1 || id > 247) throw new Error(`设备ID(板号)必须是 1..247,收到 ${id}`);
|
||||||
this.assertChannel(ch);
|
|
||||||
this.countPress([ch]);
|
|
||||||
return this.send([0x55, 0xc8, ch, 0x01, 0x55]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 第 ch 路断开(ch: 1..64) */
|
/** 第 ch 路接通(ch: 1..64)。功能05 写单线圈,值 0xFF00。 */
|
||||||
relayOff(ch: number): Promise<Buffer> {
|
relayOn(ch: number, deviceId: number = this.deviceId): Promise<Buffer> {
|
||||||
this.assertChannel(ch);
|
this.assertChannel(ch);
|
||||||
return this.send([0x55, 0xc8, ch, 0x00, 0x55]);
|
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 毫秒后断开 —— 用于模拟"按一下物理按键"。 */
|
/** 脉冲:接通 ch 路 ms 毫秒后断开 —— 用于模拟"按一下物理按键"。 */
|
||||||
async pulse(ch: number, ms = 500): Promise<void> {
|
async pulse(ch: number, ms = 500, deviceId: number = this.deviceId): Promise<void> {
|
||||||
await this.relayOn(ch);
|
await this.relayOn(ch, deviceId);
|
||||||
await new Promise((r) => setTimeout(r, ms));
|
await new Promise((r) => setTimeout(r, ms));
|
||||||
await this.relayOff(ch);
|
await this.relayOff(ch, deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把通道列表编码成 64 路状态掩码(8 字节)。
|
* 把通道列表编码成 MODBUS 写多线圈(功能0F)的 8 字节数据。
|
||||||
* 规格:高位在左、低位在右;最左字节=ch57-64,最右字节=ch1-8。
|
* 规格(MODBUS):byte0 bit0 = 起始线圈(=ch1),bit1=ch2 …… byte7 bit7=ch64(低位在前)。
|
||||||
* 已实测:ch33→第4字节 0x01、ch34→0x02、ch35→0x04(ch34+35=0x06)。
|
* 例:ch1→byte0=0x01、ch33→byte4 bit0=0x01、ch34→byte4 bit1=0x02(ch33+34=0x03)。
|
||||||
*/
|
*/
|
||||||
static buildMask(channels: number[]): Buffer {
|
static buildCoilBytes(channels: number[]): Buffer {
|
||||||
const mask = Buffer.alloc(8);
|
const bytes = Buffer.alloc(8);
|
||||||
for (const ch of channels) {
|
for (const ch of channels) {
|
||||||
if (!Number.isInteger(ch) || ch < 1 || ch > 64) throw new Error(`通道号必须是 1..64,收到 ${ch}`);
|
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 = Math.floor((ch - 1) / 8); // 0=ch1-8 ... 7=ch57-64(低位在前)
|
||||||
const byteIdx = 7 - group; // 高位在左
|
|
||||||
const bit = (ch - 1) % 8;
|
const bit = (ch - 1) % 8;
|
||||||
mask[byteIdx] |= 1 << bit;
|
bytes[byteIdx] |= 1 << bit;
|
||||||
}
|
}
|
||||||
return mask;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一帧设置全部 64 路状态:列表内通道接通、其余全部断开(用 55 C8 41 命令)。
|
* 一帧设置全部 64 路状态:列表内通道接通、其余全部断开(功能0F 写多线圈,起始线圈1,64路)。
|
||||||
* 列表多于一个通道时即"同时按下"(同一帧闭合,无先后)。传 [] 即全断。
|
* 列表多于一个通道时即"同时按下"(同一帧闭合,无先后)。传 [] 即全断。
|
||||||
*/
|
*/
|
||||||
setChannels(channels: number[]): Promise<Buffer> {
|
setChannels(channels: number[], deviceId: number = this.deviceId): Promise<Buffer> {
|
||||||
if (channels.length) this.countPress(channels); // 仅"通"计入点击;allOff([]) 不计
|
this.assertDeviceId(deviceId);
|
||||||
const mask = SerialController.buildMask(channels);
|
if (channels.length) this.countPress(channels, deviceId); // 仅"通"计入点击;allOff([]) 不计
|
||||||
return this.send([0x55, 0xc8, 0x41, ...mask, 0x55]);
|
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(): Promise<Buffer> {
|
allOff(deviceId: number = this.deviceId): Promise<Buffer> {
|
||||||
return this.setChannels([]);
|
return this.setChannels([], deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 多键脉冲:同时接通 channels 列表 ms 毫秒后全部断开 —— 模拟"同时按住多个物理键"。
|
* 多键脉冲:同时接通 channels 列表 ms 毫秒后全部断开 —— 模拟"同时按住多个物理键"。
|
||||||
* (如 remote 进配对:同时按凸键+凹键。)
|
* (如 remote 进配对:同时按凸键+凹键。)
|
||||||
*/
|
*/
|
||||||
async pulseChannels(channels: number[], ms = 500): Promise<void> {
|
async pulseChannels(channels: number[], ms = 500, deviceId: number = this.deviceId): Promise<void> {
|
||||||
await this.setChannels(channels);
|
await this.setChannels(channels, deviceId);
|
||||||
await new Promise((r) => setTimeout(r, ms));
|
await new Promise((r) => setTimeout(r, ms));
|
||||||
await this.allOff();
|
await this.allOff(deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读 64 路输入(采集)+ 64 路继电器输出状态。返回原始回包(1B DB ... 0C)。 */
|
/** 读 64 路继电器线圈状态(功能01 读线圈,起始1,64路)。返回原始回包。 */
|
||||||
readStatus(): Promise<Buffer> {
|
readStatus(deviceId: number = this.deviceId): Promise<Buffer> {
|
||||||
return this.send([0x55, 0xc7, 0x01, 0x00, 0x55]);
|
this.assertDeviceId(deviceId);
|
||||||
|
return this.send(this.frame([deviceId, 0x01, 0x00, 0x01, 0x00, 0x40]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询控制器版本(只读,不影响任何继电器)。返回应答里第 5..18 字节是版本字符串。 */
|
/**
|
||||||
async queryVersion(): Promise<{ raw: string; version: string }> {
|
* 在线探测:对指定板发一帧读线圈,回包首字节=该板号且功能码非异常(0x81)即视为在线。
|
||||||
const resp = await this.send([0x55, 0xd3, 0xd3, 0x00, 0x55]);
|
* 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 raw = resp.toString('hex').toUpperCase();
|
||||||
// 应答:AB 2D AA <版本ASCII...> BA;版本从第 5 字节(index 4)起
|
const alive = resp.length >= 2 && resp[0] === deviceId && resp[1] !== 0x81;
|
||||||
const ascii = resp.length > 5 ? resp.subarray(4, resp.length - 1).toString('ascii').replace(/[^\x20-\x7E]/g, '').trim() : '';
|
return { raw, alive, deviceId };
|
||||||
return { raw, version: ascii };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 便捷探测:打开 → 查版本 → 关闭。用于确认板子在线与波特率。 */
|
/** 便捷探测:打开 → 读线圈存活探测 → 关闭。用于确认某块板在线与波特率。 */
|
||||||
export async function probeBoard(opts: SerialOptions = {}): Promise<{ port: string; baudRate: number; alive: boolean; version: string; raw: string }> {
|
export async function probeBoard(
|
||||||
|
opts: SerialOptions = {}
|
||||||
|
): Promise<{ port: string; baudRate: number; deviceId: number; alive: boolean; raw: string }> {
|
||||||
const c = new SerialController(opts);
|
const c = new SerialController(opts);
|
||||||
await c.open();
|
await c.open();
|
||||||
try {
|
try {
|
||||||
const { version, raw } = await c.queryVersion();
|
const { alive, raw, deviceId } = await c.ping();
|
||||||
return { port: c.portPath, baudRate: opts.baudRate || parseInt(process.env.SERIAL_BAUD || '115200', 10), alive: raw.length > 0, version, raw };
|
return {
|
||||||
|
port: c.portPath,
|
||||||
|
baudRate: opts.baudRate || parseInt(process.env.SERIAL_BAUD || '115200', 10),
|
||||||
|
deviceId,
|
||||||
|
alive,
|
||||||
|
raw,
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await c.close();
|
await c.close();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue