node-red-contrib-symi-mesh 1.6.4 → 1.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +235 -15
- package/lib/device-manager.js +29 -9
- package/lib/mqtt-helper.js +3 -3
- package/lib/tcp-client.js +30 -14
- package/nodes/rs485-debug.html +51 -0
- package/nodes/rs485-debug.js +43 -0
- package/nodes/symi-485-bridge.html +43 -5
- package/nodes/symi-485-bridge.js +1147 -131
- package/nodes/symi-485-config.js +54 -4
- package/nodes/symi-device.js +7 -7
- package/nodes/symi-gateway.js +105 -48
- package/nodes/symi-mqtt.js +22 -15
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -202,7 +202,7 @@ node-red-restart
|
|
|
202
202
|
|
|
203
203
|
### 开关状态编码(重要)
|
|
204
204
|
|
|
205
|
-
**协议规则**: 每2位表示1
|
|
205
|
+
**协议规则**: 每2位表示1路,`01`=关,`10`=开,从最低位开始
|
|
206
206
|
|
|
207
207
|
| 路数 | 全关 | 全开 | 示例 |
|
|
208
208
|
|-----|------|------|------|
|
|
@@ -211,24 +211,57 @@ node-red-restart
|
|
|
211
211
|
| 3路 | 0x15 | 0x2A | 从低位开始 |
|
|
212
212
|
| 4路 | 0x55 | 0xAA | 每2位1路 |
|
|
213
213
|
| 6路 | 0x5555 | 0xAAAA | 2字节小端序 |
|
|
214
|
+
| 8路 | 0x5555 | 0xAAAA | 2字节小端序 |
|
|
215
|
+
|
|
216
|
+
#### 8路开关状态详解
|
|
217
|
+
|
|
218
|
+
8路开关使用2字节(16位)表示状态,小端序传输。实测帧示例:
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
帧格式: 53 80 [Status] [Length] [网络地址LE] [AttrType] [状态LE] [Checksum]
|
|
222
|
+
示例帧: 53 80 05 05 E4 01 45 56 55 70
|
|
223
|
+
│ │ │ │ └─────┘ │ └───┘ └─ 校验和
|
|
224
|
+
│ │ │ │ │ │ └─ 状态值 0x5556 (第1路开)
|
|
225
|
+
│ │ │ │ │ └─ AttrType=0x45 (8路开关)
|
|
226
|
+
│ │ │ │ └─ 网络地址 0x01E4
|
|
227
|
+
│ │ │ └─ 数据长度=5字节
|
|
228
|
+
│ │ └─ 状态码
|
|
229
|
+
│ └─ Opcode=0x80 (事件上报)
|
|
230
|
+
└─ 帧头
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**状态值对照表**(小端序):
|
|
234
|
+
| 状态数据 | 16位值 | 二进制 | 含义 |
|
|
235
|
+
|---------|--------|--------|------|
|
|
236
|
+
| 55 55 | 0x5555 | 01010101 01010101 | 全关 |
|
|
237
|
+
| 56 55 | 0x5556 | 01010101 01010110 | 第1路开 |
|
|
238
|
+
| 59 55 | 0x5559 | 01010101 01011001 | 第2路开 |
|
|
239
|
+
| 65 55 | 0x5565 | 01010101 01100101 | 第3路开 |
|
|
240
|
+
| 95 55 | 0x5595 | 01010101 10010101 | 第4路开 |
|
|
241
|
+
| 55 56 | 0x5655 | 01010110 01010101 | 第5路开 |
|
|
242
|
+
| 55 59 | 0x5955 | 01011001 01010101 | 第6路开 |
|
|
243
|
+
| 55 65 | 0x6555 | 01100101 01010101 | 第7路开 |
|
|
244
|
+
| 55 95 | 0x9555 | 10010101 01010101 | 第8路开 |
|
|
245
|
+
| AA AA | 0xAAAA | 10101010 10101010 | 全开 |
|
|
214
246
|
|
|
215
247
|
### 常用消息类型
|
|
216
248
|
|
|
217
249
|
| 消息类型 | 代码 | 数据格式 | 范围/说明 |
|
|
218
250
|
|---------|------|---------|----------|
|
|
219
|
-
| 开关状态 | 0x02 | 1
|
|
251
|
+
| 开关状态 | 0x02 | 1字节 | 1-4路开关,见上表 |
|
|
220
252
|
| 亮度 | 0x03 | 1字节 | 0-100% |
|
|
221
253
|
| 色温 | 0x04 | 1字节 | 0%=暖白, 100%=冷白 |
|
|
222
254
|
| 窗帘动作 | 0x05 | 1字节 | 1=开/2=关/3=停 |
|
|
223
255
|
| 窗帘位置 | 0x06 | 1字节 | 0-100% |
|
|
224
256
|
| 人体感应 | 0x0C | 1字节 | 0=无活动/1=检测到 |
|
|
225
257
|
| 插卡状态 | 0x0E | 1字节 | 0=无卡/1=有卡 |
|
|
226
|
-
|
|
|
227
|
-
| 当前温度 | 0x16 | 2字节LE | 温度*100
|
|
228
|
-
| 目标温度 | 0x1B |
|
|
258
|
+
| 湿度 | 0x17 | 1字节 | 0-100% |
|
|
259
|
+
| 当前温度 | 0x16 | 2字节LE | 温度*100(如2500=25.00°C)|
|
|
260
|
+
| 目标温度 | 0x1B | 1字节 | 16-30°C(直接温度值)|
|
|
229
261
|
| 风速 | 0x1C | 1字节 | 1=高/2=中/3=低/4=自动 |
|
|
230
262
|
| 模式 | 0x1D | 1字节 | 1=制冷/2=制热/3=送风/4=除湿 |
|
|
231
|
-
| 6
|
|
263
|
+
| 6-8路开关 | 0x45 | 2字节LE | 多路开关状态,见上表 |
|
|
264
|
+
| 五色RGB | 0x4C | 5字节 | R/G/B/WW/CW (各0-255) |
|
|
232
265
|
|
|
233
266
|
## MQTT主题结构
|
|
234
267
|
|
|
@@ -531,10 +564,35 @@ Mesh 四键开关 第2路 ↔ RS485 六键开关 第5路 地址:2 ✓ 支持
|
|
|
531
564
|
|-------------|--------------|---------|
|
|
532
565
|
| 开关-按键N | 开关 | 开/关状态 |
|
|
533
566
|
| 温控器 | 空调 | 开关、温度、模式、风速 |
|
|
567
|
+
| 三合一面板 | 空调+新风+地暖 | 分别配置,独立同步 |
|
|
534
568
|
| 调光灯 | 调光器 | 开关、亮度 |
|
|
535
569
|
|
|
536
570
|
**部分同步**:如果RS485协议不提供某些功能点,只同步双方都支持的内容
|
|
537
571
|
|
|
572
|
+
### 三合一面板配置
|
|
573
|
+
|
|
574
|
+
三合一面板(温控器类型)可同时控制空调、新风、地暖,需要创建3个独立映射:
|
|
575
|
+
|
|
576
|
+
#### 配置示例(话语前湾协议)
|
|
577
|
+
|
|
578
|
+
| Mesh设备 | RS485设备类型 | 从机地址 | 说明 |
|
|
579
|
+
|---------|--------------|---------|------|
|
|
580
|
+
| 三合一面板_xxx | 次卧1空调 | 0 | 空调寄存器0x0FA4-0x0FA7 |
|
|
581
|
+
| 三合一面板_xxx | 新风 | 60 | 开关0x0039,风速0x004B |
|
|
582
|
+
| 三合一面板_xxx | 次卧1地暖 | 62 | 开关0x0039,温度0x0043 |
|
|
583
|
+
|
|
584
|
+
#### 话语前湾地暖/新风从机地址
|
|
585
|
+
|
|
586
|
+
| 设备 | 从机地址 | 十六进制 |
|
|
587
|
+
|------|---------|---------|
|
|
588
|
+
| 客餐厅地暖 | 60 | 0x3C |
|
|
589
|
+
| 新风 | 60 | 0x3C |
|
|
590
|
+
| 主卧地暖 | 61 | 0x3D |
|
|
591
|
+
| 次卧1地暖 | 62 | 0x3E |
|
|
592
|
+
| 次卧2地暖 | 63 | 0x3F |
|
|
593
|
+
|
|
594
|
+
> **注意**:新风和客餐厅地暖共用从机地址60,但开关值不同(新风开=1,地暖开=2),请分别创建映射。
|
|
595
|
+
|
|
538
596
|
### 内置协议支持
|
|
539
597
|
|
|
540
598
|
| 品牌 | 支持设备 |
|
|
@@ -992,6 +1050,131 @@ A: 每个房间部署一个云端同步节点,配置对应的酒店ID和房间
|
|
|
992
1050
|
- 获取失败时自动使用上次缓存的数据
|
|
993
1051
|
- 可以禁用节点,不影响其他功能正常运行
|
|
994
1052
|
|
|
1053
|
+
## RS485网桥节点
|
|
1054
|
+
|
|
1055
|
+
### 功能概述
|
|
1056
|
+
|
|
1057
|
+
RS485网桥节点实现Mesh网关与第三方RS485设备的**双向同步**:
|
|
1058
|
+
|
|
1059
|
+
- **事件驱动**:状态变化时立即同步,无轮询
|
|
1060
|
+
- **多协议支持**:标准Modbus RTU、杜亚窗帘、自定义十六进制码
|
|
1061
|
+
- **按键对按键**:Mesh开关的每一路可独立映射到RS485的任意按键
|
|
1062
|
+
- **防循环保护**:智能防抖机制,避免无限循环
|
|
1063
|
+
- **内存安全**:命令队列限制100条,缓存自动清理
|
|
1064
|
+
|
|
1065
|
+
### 配置说明
|
|
1066
|
+
|
|
1067
|
+
#### 1. 添加RS485配置节点
|
|
1068
|
+
|
|
1069
|
+
首先配置RS485连接(串口或TCP):
|
|
1070
|
+
|
|
1071
|
+
- **连接方式**: 串口 或 TCP
|
|
1072
|
+
- **串口模式**: 选择串口路径,波特率默认9600
|
|
1073
|
+
- **TCP模式**: 输入IP地址和端口
|
|
1074
|
+
|
|
1075
|
+
#### 2. 添加RS485桥接节点
|
|
1076
|
+
|
|
1077
|
+
配置实体映射:
|
|
1078
|
+
|
|
1079
|
+
| 配置项 | 说明 |
|
|
1080
|
+
|-------|------|
|
|
1081
|
+
| **Mesh实体** | 选择要同步的Mesh设备 |
|
|
1082
|
+
| **Mesh按键** | 选择具体哪一路(多路开关) |
|
|
1083
|
+
| **品牌** | 选择协议品牌(话语前湾、杜亚、自定义等) |
|
|
1084
|
+
| **设备类型** | 选择设备类型(决定寄存器配置) |
|
|
1085
|
+
| **RS485按键** | 选择RS485端的哪一路 |
|
|
1086
|
+
| **从机地址** | Modbus从机地址(0-255,空调填0) |
|
|
1087
|
+
|
|
1088
|
+
### 话语前湾协议配置
|
|
1089
|
+
|
|
1090
|
+
话语前湾项目使用以下协议:
|
|
1091
|
+
|
|
1092
|
+
#### 开关设备 (A4B3)
|
|
1093
|
+
|
|
1094
|
+
| 设备 | 从机地址 | 配置方式 |
|
|
1095
|
+
|-----|---------|---------|
|
|
1096
|
+
| 客厅主灯 | 3 | 选择"话语前湾" → "一键开关",地址填3 |
|
|
1097
|
+
| 餐厅主灯 | 3 | 选择"话语前湾" → "三键开关",按键3,地址填3 |
|
|
1098
|
+
| 玄关灯带 | 1 | 选择"话语前湾" → "四键开关",按键4,地址填1 |
|
|
1099
|
+
|
|
1100
|
+
**寄存器映射**:
|
|
1101
|
+
- 按键1: 0x1031
|
|
1102
|
+
- 按键2: 0x1032
|
|
1103
|
+
- 按键3: 0x1033
|
|
1104
|
+
- 按键4: 0x1034
|
|
1105
|
+
- 按键5: 0x1035
|
|
1106
|
+
- 按键6: 0x1036
|
|
1107
|
+
|
|
1108
|
+
#### 空调设备 (A5B5)
|
|
1109
|
+
|
|
1110
|
+
**所有空调从机地址都是0**,通过设备类型区分房间:
|
|
1111
|
+
|
|
1112
|
+
| 房间 | 设备类型 | 寄存器范围 |
|
|
1113
|
+
|-----|---------|-----------|
|
|
1114
|
+
| 客厅空调 | ac_living | 0x0FA0-0x0FA3 |
|
|
1115
|
+
| 次卧1空调 | ac_bedroom2_1 | 0x0FA4-0x0FA7 |
|
|
1116
|
+
| 次卧2空调 | ac_bedroom2_2 | 0x0FA8-0x0FAB |
|
|
1117
|
+
| 主卧空调 | ac_master | 0x0FAC-0x0FAF |
|
|
1118
|
+
|
|
1119
|
+
**配置示例**:选择"话语前湾" → "主卧空调",地址填**0**
|
|
1120
|
+
|
|
1121
|
+
#### 地暖设备 (A3B3)
|
|
1122
|
+
|
|
1123
|
+
| 房间 | 从机地址 | 说明 |
|
|
1124
|
+
|-----|---------|------|
|
|
1125
|
+
| 客餐厅 | 60 (0x3C) | 开关:0x0039, 温度:0x0043 |
|
|
1126
|
+
| 主卧 | 61 (0x3D) | 开关:0x0039, 温度:0x0043 |
|
|
1127
|
+
| 次卧1 | 62 (0x3E) | 开关:0x0039, 温度:0x0043 |
|
|
1128
|
+
| 次卧2 | 63 (0x3F) | 开关:0x0039, 温度:0x0043 |
|
|
1129
|
+
|
|
1130
|
+
**配置示例**:选择"话语前湾" → "地暖",地址填60/61/62/63
|
|
1131
|
+
|
|
1132
|
+
#### 新风设备 (A3B3)
|
|
1133
|
+
|
|
1134
|
+
| 参数 | 从机地址 | 说明 |
|
|
1135
|
+
|-----|---------|------|
|
|
1136
|
+
| 新风 | 60 (0x3C) | 开关:0x0039(值01), 风速:0x004B |
|
|
1137
|
+
|
|
1138
|
+
**配置示例**:选择"话语前湾" → "新风",地址填60
|
|
1139
|
+
|
|
1140
|
+
#### 杜亚窗帘 (A6B6)
|
|
1141
|
+
|
|
1142
|
+
| 窗帘 | 地址高 | 地址低 |
|
|
1143
|
+
|-----|-------|-------|
|
|
1144
|
+
| 主卧窗帘 | 2 | 2 |
|
|
1145
|
+
| 主卧窗纱 | 1 | 1 |
|
|
1146
|
+
|
|
1147
|
+
**配置示例**:选择"杜亚窗帘" → "窗帘",地址高填2,地址低填2
|
|
1148
|
+
|
|
1149
|
+
### 双向同步流程
|
|
1150
|
+
|
|
1151
|
+
```
|
|
1152
|
+
Mesh面板按键 → 网关 → device-state-changed事件
|
|
1153
|
+
↓
|
|
1154
|
+
RS485桥接节点检测状态变化
|
|
1155
|
+
↓
|
|
1156
|
+
匹配映射配置 + 检查isUserControl
|
|
1157
|
+
↓
|
|
1158
|
+
生成RS485帧 → 发送到485总线
|
|
1159
|
+
↓
|
|
1160
|
+
485设备执行动作
|
|
1161
|
+
|
|
1162
|
+
485面板/遥控器 → 485总线 → RS485桥接节点接收帧
|
|
1163
|
+
↓
|
|
1164
|
+
解析帧 + 匹配映射配置
|
|
1165
|
+
↓
|
|
1166
|
+
调用gateway.sendControl()
|
|
1167
|
+
↓
|
|
1168
|
+
Mesh设备执行动作 → HA状态更新
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
### 注意事项
|
|
1172
|
+
|
|
1173
|
+
1. **HA只对接Mesh设备**:485设备不会发布到HA,避免重复实体
|
|
1174
|
+
2. **窗帘防抖**:窗帘操作有1-2秒防抖,防止电机反馈误触发
|
|
1175
|
+
3. **初始化延迟**:启动后20秒才开始同步,等待Mesh设备发现完成
|
|
1176
|
+
4. **场景执行**:场景触发时多设备状态会排队同步,每命令间隔50ms
|
|
1177
|
+
|
|
995
1178
|
## 开发者信息
|
|
996
1179
|
|
|
997
1180
|
### 项目结构
|
|
@@ -1061,13 +1244,50 @@ node-red-contrib-symi-mesh/
|
|
|
1061
1244
|
|
|
1062
1245
|
## 更新日志
|
|
1063
1246
|
|
|
1064
|
-
### v1.6.
|
|
1065
|
-
-
|
|
1066
|
-
-
|
|
1067
|
-
-
|
|
1068
|
-
-
|
|
1069
|
-
-
|
|
1070
|
-
|
|
1247
|
+
### v1.6.6 (2025-12-09)
|
|
1248
|
+
- **三合一面板完整双向同步**:支持空调+新风+地暖独立RS485映射
|
|
1249
|
+
- 三合一0x94协议完整解析:空调(开关/模式/风速/温度)、地暖(开关/温度)、新风(开关/风速)
|
|
1250
|
+
- Mesh→RS485:三合一状态变化自动发送对应RS485帧
|
|
1251
|
+
- RS485→Mesh:RS485帧自动同步到三合一面板对应功能
|
|
1252
|
+
- 支持climateSwitch/climateMode/fanMode/floorHeatingSwitch/freshAirSwitch等字段
|
|
1253
|
+
- **开关双向同步修复**:修复米家App全开/全关只触发部分按键的问题
|
|
1254
|
+
- 命令队列防抖逻辑修复:只有完全相同映射才合并,不同映射独立处理
|
|
1255
|
+
- 支持同一Mesh设备多通道分别绑定不同RS485设备
|
|
1256
|
+
- **RS485多映射匹配**:修复同一从机地址多个映射时只匹配第一个的问题
|
|
1257
|
+
- findAllRS485Mappings返回所有匹配的映射
|
|
1258
|
+
- rs485Channel精确匹配:根据寄存器地址确定通道
|
|
1259
|
+
- **网络异常处理增强**:彻底修复Node.js 18+ AggregateError导致崩溃的问题
|
|
1260
|
+
- 全局uncaughtException/unhandledRejection处理器
|
|
1261
|
+
- TCP连接过程同步错误捕获
|
|
1262
|
+
- 只捕获网络相关错误,不影响其他异常
|
|
1263
|
+
- **三合一面板自动识别**:设备列表API返回isThreeInOne标志
|
|
1264
|
+
- **窗帘双协议兼容**:同时支持米家和小程序两种控制协议
|
|
1265
|
+
- 米家协议: `subOpcode=0x06, status: 0=打开中, 1=关闭中, 2=停止`
|
|
1266
|
+
- 小程序协议: `subOpcode=0x05, status: 1=打开, 2=关闭, 3=停止`
|
|
1267
|
+
- **话语前湾协议完善**:
|
|
1268
|
+
- 空调:客厅/主卧/次卧1/次卧2,寄存器0x0FA0-0x0FAF
|
|
1269
|
+
- 地暖:客餐厅(60)/主卧(61)/次卧1(62)/次卧2(63),寄存器0x0039/0x0043
|
|
1270
|
+
- 新风:从机60,开关0x0039(开=1),风速0x004B(高=2,低=0)
|
|
1271
|
+
- **地址输入修复**:允许从机地址为0(话语前湾空调地址是0)
|
|
1272
|
+
- **内存安全**:命令队列限制100条,节点关闭时清理所有缓存
|
|
1273
|
+
- **文档更新**:添加三合一面板RS485配置说明和协议对照表
|
|
1274
|
+
|
|
1275
|
+
### v1.6.5 (2025-12-06)
|
|
1276
|
+
- **杜亚窗帘协议**:原生支持杜亚窗帘协议(A6B6),2字节地址,自动CRC16计算
|
|
1277
|
+
- 帧格式:55 [地址高] [地址低] 03 [动作/位置] [CRC16低] [CRC16高]
|
|
1278
|
+
- 支持打开(01)、关闭(02)、暂停(03)、百分比(04+位置)
|
|
1279
|
+
- **窗帘控制智能判断**:根据当前位置判断方向
|
|
1280
|
+
- 位置>=50% + curtainStatus变化 → 发关闭码
|
|
1281
|
+
- 位置<50% + curtainStatus变化 → 发打开码
|
|
1282
|
+
- 暂停(curtainStatus=3)最高优先级
|
|
1283
|
+
- **窗帘百分比模式修复**:修复百分比控制后开/关命令失效的问题
|
|
1284
|
+
- 百分比控制时进入百分比模式(inPosMode)
|
|
1285
|
+
- 窗帘到位(curtainStatus=0)后自动退出百分比模式
|
|
1286
|
+
- 退出后开/关命令可正常发送
|
|
1287
|
+
- **发码防抖**:500ms内不重复发相同码,避免Mesh状态混乱
|
|
1288
|
+
- **设备类型过滤**:映射只响应对应类型的状态变化
|
|
1289
|
+
- **RS485调试增强**:新增协议测试发送功能
|
|
1290
|
+
- **初始化延迟**:20秒,避免部署时误发命令
|
|
1071
1291
|
|
|
1072
1292
|
## 许可证
|
|
1073
1293
|
|
|
@@ -1080,8 +1300,8 @@ Copyright (c) 2025 SYMI 亖米
|
|
|
1080
1300
|
## 关于
|
|
1081
1301
|
|
|
1082
1302
|
**作者**: SYMI 亖米
|
|
1083
|
-
**版本**: 1.6.
|
|
1303
|
+
**版本**: 1.6.6
|
|
1084
1304
|
**协议**: 蓝牙MESH网关(初级版)串口协议V1.0
|
|
1085
|
-
**最后更新**: 2025-12-
|
|
1305
|
+
**最后更新**: 2025-12-09
|
|
1086
1306
|
**仓库**: https://github.com/symi-daguo/node-red-contrib-symi-mesh
|
|
1087
1307
|
**npm包**: https://www.npmjs.com/package/node-red-contrib-symi-mesh
|
package/lib/device-manager.js
CHANGED
|
@@ -100,16 +100,36 @@ class DeviceInfo {
|
|
|
100
100
|
break;
|
|
101
101
|
case 0x05:
|
|
102
102
|
// CURT_RUN_STATUS - 窗帘运行状态
|
|
103
|
-
//
|
|
103
|
+
// 【兼容两种协议】:
|
|
104
|
+
// 米家协议: 0=打开中, 1=关闭中, 2=停止
|
|
105
|
+
// 小程序协议: 1=打开, 2=关闭, 3=停止
|
|
106
|
+
// 通过status=0(仅米家)和status=3(仅小程序)来区分协议类型
|
|
104
107
|
if (parameters.length > 0) {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
const status = parameters[0];
|
|
109
|
+
this.state.curtainStatus = status;
|
|
110
|
+
|
|
111
|
+
// 检测协议类型:status=0只在米家出现,status=3只在小程序出现
|
|
112
|
+
if (status === 0) {
|
|
113
|
+
this.state.curtainProtocol = 'mijia';
|
|
114
|
+
this.state.curtainAction = 'opening';
|
|
115
|
+
} else if (status === 3) {
|
|
116
|
+
this.state.curtainProtocol = 'miniprogram';
|
|
117
|
+
this.state.curtainAction = 'stopped';
|
|
118
|
+
} else if (status === 1) {
|
|
119
|
+
// status=1: 米家=关闭中, 小程序=打开
|
|
120
|
+
if (this.state.curtainProtocol === 'mijia') {
|
|
121
|
+
this.state.curtainAction = 'closing';
|
|
122
|
+
} else {
|
|
123
|
+
this.state.curtainAction = 'opening';
|
|
124
|
+
}
|
|
125
|
+
} else if (status === 2) {
|
|
126
|
+
// status=2: 米家=停止, 小程序=关闭
|
|
127
|
+
if (this.state.curtainProtocol === 'mijia') {
|
|
128
|
+
this.state.curtainAction = 'stopped';
|
|
129
|
+
} else {
|
|
130
|
+
this.state.curtainAction = 'closing';
|
|
131
|
+
}
|
|
111
132
|
}
|
|
112
|
-
this.state.curtainStatus = newStatus;
|
|
113
133
|
}
|
|
114
134
|
break;
|
|
115
135
|
case 0x06:
|
|
@@ -168,7 +188,7 @@ class DeviceInfo {
|
|
|
168
188
|
// 目标温度TMPC_TEMP:1字节,直接温度值(16-30°C)
|
|
169
189
|
if (parameters.length > 0) {
|
|
170
190
|
const temp = parameters[0];
|
|
171
|
-
if (temp >=
|
|
191
|
+
if (temp >= 16 && temp <= 30) {
|
|
172
192
|
this.state.targetTemp = temp;
|
|
173
193
|
// 不推断开关状态,完全由0x02消息决定
|
|
174
194
|
}
|
package/lib/mqtt-helper.js
CHANGED
|
@@ -627,11 +627,11 @@ function convertStateValue(entityType, attrType, value, deviceType = null) {
|
|
|
627
627
|
|
|
628
628
|
case 'climate':
|
|
629
629
|
if (attrType === 0x16) {
|
|
630
|
-
// 当前温度:温度*100 -> 温度
|
|
630
|
+
// 当前温度:温度*100 -> 温度 (2字节小端序)
|
|
631
631
|
return (value / 100).toFixed(1);
|
|
632
632
|
} else if (attrType === 0x1B) {
|
|
633
|
-
//
|
|
634
|
-
return
|
|
633
|
+
// 目标温度:直接温度值 (1字节,16-30°C)
|
|
634
|
+
return value.toString();
|
|
635
635
|
} else if (attrType === 0x1C) {
|
|
636
636
|
// 风速:1=high, 2=medium, 3=low, 4=auto
|
|
637
637
|
const fanModes = { 1: 'high', 2: 'medium', 3: 'low', 4: 'auto' };
|
package/lib/tcp-client.js
CHANGED
|
@@ -40,7 +40,7 @@ class TCPClient extends EventEmitter {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
this.client = new net.Socket();
|
|
43
|
-
|
|
43
|
+
// Keep-Alive 已移除 - 可能导致连接问题
|
|
44
44
|
|
|
45
45
|
let resolved = false;
|
|
46
46
|
let rejected = false;
|
|
@@ -57,21 +57,33 @@ class TCPClient extends EventEmitter {
|
|
|
57
57
|
}
|
|
58
58
|
}, 10000);
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
// 捕获connect过程中的同步错误(如AggregateError)
|
|
61
|
+
try {
|
|
62
|
+
this.client.connect(this.port, this.host, () => {
|
|
63
|
+
if (!resolved && !rejected) {
|
|
64
|
+
resolved = true;
|
|
65
|
+
clearTimeout(timeout);
|
|
66
|
+
this.connected = true;
|
|
67
|
+
this.logger.log(`Connected to gateway at ${this.host}:${this.port}`);
|
|
68
|
+
this.emit('connected');
|
|
69
|
+
|
|
70
|
+
if (!this.processingQueue) {
|
|
71
|
+
this.startQueueProcessor();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
resolve();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
} catch (connectError) {
|
|
78
|
+
// 捕获同步错误(包括AggregateError)
|
|
61
79
|
if (!resolved && !rejected) {
|
|
62
|
-
|
|
80
|
+
rejected = true;
|
|
63
81
|
clearTimeout(timeout);
|
|
64
|
-
this.
|
|
65
|
-
this.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (!this.processingQueue) {
|
|
69
|
-
this.startQueueProcessor();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
resolve();
|
|
82
|
+
this.logger.warn(`TCP连接同步错误: ${connectError.message || connectError}`);
|
|
83
|
+
this.handleDisconnect();
|
|
84
|
+
reject(connectError);
|
|
73
85
|
}
|
|
74
|
-
}
|
|
86
|
+
}
|
|
75
87
|
|
|
76
88
|
this.client.on('data', (data) => {
|
|
77
89
|
try {
|
|
@@ -88,9 +100,13 @@ class TCPClient extends EventEmitter {
|
|
|
88
100
|
|
|
89
101
|
this.client.on('error', (error) => {
|
|
90
102
|
clearTimeout(timeout);
|
|
103
|
+
// AggregateError特殊处理(Node.js 18+的IPv4/IPv6连接失败)
|
|
104
|
+
if (error.name === 'AggregateError' || error.errors) {
|
|
105
|
+
this.logger.warn(`TCP连接失败: 无法连接到 ${this.host}:${this.port}`);
|
|
106
|
+
}
|
|
91
107
|
// 只在首次连接或重要错误时记录,避免大量重复日志
|
|
92
108
|
// ECONNRESET通常是网络波动,不记录错误
|
|
93
|
-
if (!this.connected && error.code !== 'ECONNREFUSED' && error.code !== 'ECONNRESET') {
|
|
109
|
+
else if (!this.connected && error.code !== 'ECONNREFUSED' && error.code !== 'ECONNRESET') {
|
|
94
110
|
this.logger.error('TCP client error:', error.message);
|
|
95
111
|
}
|
|
96
112
|
|
package/nodes/rs485-debug.html
CHANGED
|
@@ -116,6 +116,45 @@
|
|
|
116
116
|
}
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
+
// 发送测试
|
|
120
|
+
$('#btn-test-send').on('click', function() {
|
|
121
|
+
var hexInput = $('#test-hex-input').val().trim();
|
|
122
|
+
if (!hexInput) {
|
|
123
|
+
$('#test-send-result').html('<span style="color:orange;">请输入16进制数据</span>');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
var rs485ConfigId = $('#node-input-rs485Config').val();
|
|
127
|
+
if (!rs485ConfigId) {
|
|
128
|
+
$('#test-send-result').html('<span style="color:red;">请先选择RS485连接</span>');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
$('#btn-test-send').prop('disabled', true);
|
|
132
|
+
$('#test-send-result').html('<i class="fa fa-spinner fa-spin"></i> 发送中...');
|
|
133
|
+
$.ajax({
|
|
134
|
+
url: '/rs485-debug/test-send',
|
|
135
|
+
method: 'POST',
|
|
136
|
+
contentType: 'application/json',
|
|
137
|
+
data: JSON.stringify({ rs485ConfigId: rs485ConfigId, hex: hexInput }),
|
|
138
|
+
success: function(res) {
|
|
139
|
+
$('#test-send-result').html('<span style="color:green;">✓ 已发送: ' + res.hex + ' (' + res.bytes + '字节)</span>');
|
|
140
|
+
},
|
|
141
|
+
error: function(xhr) {
|
|
142
|
+
var msg = xhr.responseJSON ? xhr.responseJSON.error : '发送失败';
|
|
143
|
+
$('#test-send-result').html('<span style="color:red;">✗ ' + msg + '</span>');
|
|
144
|
+
},
|
|
145
|
+
complete: function() {
|
|
146
|
+
$('#btn-test-send').prop('disabled', false);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// 回车发送
|
|
152
|
+
$('#test-hex-input').on('keypress', function(e) {
|
|
153
|
+
if (e.which === 13) {
|
|
154
|
+
$('#btn-test-send').click();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
119
158
|
// 初始加载并启动自动刷新
|
|
120
159
|
loadHistory();
|
|
121
160
|
startAutoRefresh();
|
|
@@ -181,6 +220,18 @@
|
|
|
181
220
|
<input type="number" id="node-input-maxMessages" min="10" max="1000" style="width:70%">
|
|
182
221
|
</div>
|
|
183
222
|
|
|
223
|
+
<div class="debug-section">
|
|
224
|
+
<h4><i class="fa fa-paper-plane"></i> 发送测试</h4>
|
|
225
|
+
<div style="display:flex; gap:8px; align-items:center;">
|
|
226
|
+
<input type="text" id="test-hex-input" placeholder="输入16进制数据,如: 55 01 01 03 01 B9 00"
|
|
227
|
+
style="flex:1; font-family:monospace; padding:6px;">
|
|
228
|
+
<button type="button" id="btn-test-send" class="red-ui-button" style="min-width:80px;">
|
|
229
|
+
<i class="fa fa-send"></i> 发送
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
<div id="test-send-result" style="margin-top:6px; font-size:11px; color:#666;"></div>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
184
235
|
<div class="debug-section">
|
|
185
236
|
<h4><i class="fa fa-terminal"></i> 通信数据预览 <span id="debug-status" style="font-weight:normal;color:#888;font-size:11px;"></span></h4>
|
|
186
237
|
<div id="debug-history"></div>
|
package/nodes/rs485-debug.js
CHANGED
|
@@ -217,4 +217,47 @@ module.exports = function(RED) {
|
|
|
217
217
|
res.json({ success: false, error: '节点未找到' });
|
|
218
218
|
}
|
|
219
219
|
});
|
|
220
|
+
|
|
221
|
+
// API: 测试发送十六进制数据
|
|
222
|
+
RED.httpAdmin.post('/rs485-debug/test-send', function(req, res) {
|
|
223
|
+
const { rs485ConfigId, hex } = req.body || {};
|
|
224
|
+
|
|
225
|
+
if (!rs485ConfigId) {
|
|
226
|
+
return res.status(400).json({ error: '未指定RS485连接' });
|
|
227
|
+
}
|
|
228
|
+
if (!hex) {
|
|
229
|
+
return res.status(400).json({ error: '未指定发送数据' });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 解析16进制字符串
|
|
233
|
+
const hexClean = hex.replace(/[\s,]/g, '');
|
|
234
|
+
if (!/^[0-9A-Fa-f]+$/.test(hexClean)) {
|
|
235
|
+
return res.status(400).json({ error: '无效的16进制格式' });
|
|
236
|
+
}
|
|
237
|
+
if (hexClean.length % 2 !== 0) {
|
|
238
|
+
return res.status(400).json({ error: '16进制长度必须是偶数' });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const buffer = Buffer.from(hexClean, 'hex');
|
|
242
|
+
const formattedHex = buffer.toString('hex').toUpperCase().match(/.{2}/g).join(' ');
|
|
243
|
+
|
|
244
|
+
// 获取RS485配置节点
|
|
245
|
+
const rs485Config = RED.nodes.getNode(rs485ConfigId);
|
|
246
|
+
if (!rs485Config) {
|
|
247
|
+
return res.status(404).json({ error: 'RS485连接节点未找到,请先部署' });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!rs485Config.connected) {
|
|
251
|
+
return res.status(503).json({ error: 'RS485未连接' });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 发送数据
|
|
255
|
+
rs485Config.send(buffer)
|
|
256
|
+
.then(() => {
|
|
257
|
+
res.json({ success: true, hex: formattedHex, bytes: buffer.length });
|
|
258
|
+
})
|
|
259
|
+
.catch(err => {
|
|
260
|
+
res.status(500).json({ error: err.message });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
220
263
|
};
|
|
@@ -160,6 +160,18 @@
|
|
|
160
160
|
|
|
161
161
|
mappings.forEach(function(m, idx) {
|
|
162
162
|
var row = $('<div class="mapping-row" data-idx="' + idx + '" style="flex-wrap:wrap;"></div>');
|
|
163
|
+
// 杜亚窗帘使用2字节地址
|
|
164
|
+
var addrHtml = '';
|
|
165
|
+
if (m.brand === 'duya') {
|
|
166
|
+
addrHtml = '<div class="addr-col duya-addr" style="display:flex;gap:2px;">' +
|
|
167
|
+
'<input type="number" class="addr-high" value="' + (m.addrHigh || 1) + '" min="0" max="255" style="width:40px;" title="地址高字节" placeholder="高">' +
|
|
168
|
+
'<input type="number" class="addr-low" value="' + (m.addrLow || 1) + '" min="0" max="255" style="width:40px;" title="地址低字节" placeholder="低">' +
|
|
169
|
+
'</div>';
|
|
170
|
+
} else {
|
|
171
|
+
addrHtml = '<div class="addr-col normal-addr">' +
|
|
172
|
+
'<input type="number" class="addr-input" value="' + (m.address !== undefined ? m.address : 1) + '" min="0" max="255" title="Modbus从机地址(0-255)">' +
|
|
173
|
+
'</div>';
|
|
174
|
+
}
|
|
163
175
|
row.html(
|
|
164
176
|
'<div class="mesh-col">' +
|
|
165
177
|
' <select class="mesh-select">' + getMeshOptions(m.meshMac) + '</select>' +
|
|
@@ -173,9 +185,7 @@
|
|
|
173
185
|
' <select class="device-select">' + getDeviceOptions(m.brand, m.device) + '</select>' +
|
|
174
186
|
' <span class="rs485-ch-wrap">' + getRS485ChannelOptions(m.brand, m.device, m.rs485Channel || 1) + '</span>' +
|
|
175
187
|
'</div>' +
|
|
176
|
-
|
|
177
|
-
' <input type="number" class="addr-input" value="' + (m.address || 1) + '" min="1" max="255" title="Modbus地址">' +
|
|
178
|
-
'</div>' +
|
|
188
|
+
addrHtml +
|
|
179
189
|
'<div class="del-col"><button type="button" class="red-ui-button red-ui-button-small btn-remove" title="删除"><i class="fa fa-times"></i></button></div>'
|
|
180
190
|
);
|
|
181
191
|
container.append(row);
|
|
@@ -203,6 +213,12 @@
|
|
|
203
213
|
mappings[idx].device = '';
|
|
204
214
|
mappings[idx].rs485Channel = 1;
|
|
205
215
|
mappings[idx].customCodes = {};
|
|
216
|
+
// 杜亚窗帘切换地址输入框
|
|
217
|
+
if (brandId === 'duya') {
|
|
218
|
+
mappings[idx].addrHigh = 1;
|
|
219
|
+
mappings[idx].addrLow = 1;
|
|
220
|
+
}
|
|
221
|
+
renderMappings(); // 重新渲染以更新地址输入框
|
|
206
222
|
});
|
|
207
223
|
|
|
208
224
|
container.find('.device-select').off('change').on('change', function() {
|
|
@@ -242,7 +258,19 @@
|
|
|
242
258
|
|
|
243
259
|
container.find('.addr-input').off('change').on('change', function() {
|
|
244
260
|
var idx = $(this).closest('.mapping-row').data('idx');
|
|
245
|
-
|
|
261
|
+
var val = $(this).val();
|
|
262
|
+
mappings[idx].address = (val !== '' && val !== undefined) ? parseInt(val) : 1;
|
|
263
|
+
if (isNaN(mappings[idx].address)) mappings[idx].address = 1;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// 杜亚2字节地址输入
|
|
267
|
+
container.find('.addr-high').off('change').on('change', function() {
|
|
268
|
+
var idx = $(this).closest('.mapping-row').data('idx');
|
|
269
|
+
mappings[idx].addrHigh = parseInt($(this).val()) || 1;
|
|
270
|
+
});
|
|
271
|
+
container.find('.addr-low').off('change').on('change', function() {
|
|
272
|
+
var idx = $(this).closest('.mapping-row').data('idx');
|
|
273
|
+
mappings[idx].addrLow = parseInt($(this).val()) || 1;
|
|
246
274
|
});
|
|
247
275
|
|
|
248
276
|
container.find('.btn-remove').off('click').on('click', function() {
|
|
@@ -326,14 +354,24 @@
|
|
|
326
354
|
oneditsave: function() {
|
|
327
355
|
var mappings = [];
|
|
328
356
|
$('#mapping-list .mapping-row').each(function() {
|
|
357
|
+
// 获取地址值,允许0(话语前湾空调地址是0)
|
|
358
|
+
var addrVal = $(this).find('.addr-input').val();
|
|
359
|
+
var address = (addrVal !== '' && addrVal !== undefined) ? parseInt(addrVal) : 1;
|
|
360
|
+
if (isNaN(address)) address = 1;
|
|
361
|
+
|
|
329
362
|
var m = {
|
|
330
363
|
meshMac: $(this).find('.mesh-select').val() || '',
|
|
331
364
|
meshChannel: parseInt($(this).find('.mesh-channel').val()) || 1,
|
|
332
365
|
brand: $(this).find('.brand-select').val() || '',
|
|
333
366
|
device: $(this).find('.device-select').val() || '',
|
|
334
|
-
address:
|
|
367
|
+
address: address,
|
|
335
368
|
rs485Channel: parseInt($(this).find('.rs485-channel').val()) || 1
|
|
336
369
|
};
|
|
370
|
+
// 保存杜亚窗帘2字节地址
|
|
371
|
+
if (m.brand === 'duya') {
|
|
372
|
+
m.addrHigh = parseInt($(this).find('.addr-high').val()) || 1;
|
|
373
|
+
m.addrLow = parseInt($(this).find('.addr-low').val()) || 1;
|
|
374
|
+
}
|
|
337
375
|
// 保存自定义码
|
|
338
376
|
if (m.brand === 'custom') {
|
|
339
377
|
m.customCodes = {};
|