node-red-contrib-symi-mesh 1.6.9 → 1.7.1

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 CHANGED
@@ -382,6 +382,7 @@ npm install node-red-contrib-home-assistant-websocket
382
382
  - 支持与HA中任意实体同步
383
383
  - 配置灵活,易于调整
384
384
 
385
+
385
386
  ## 协议说明
386
387
 
387
388
  ### 核心协议格式
@@ -603,7 +604,7 @@ Tab分隔,每行一个实体:
603
604
 
604
605
  #### 注意事项
605
606
 
606
- 1. **20秒初始化延迟**:部署后前20秒不同步,等待Mesh设备发现完成
607
+ 1. **10秒初始化延迟**:部署后前10秒不同步,等待Mesh设备发现完成
607
608
  2. **首次状态缓存**:启动后第一次状态仅缓存,第二次操作才会同步
608
609
  3. **防死循环**:KNX控制后1秒内,Mesh状态变化不会反向同步
609
610
 
@@ -742,34 +743,67 @@ Mesh 四键开关 第2路 ↔ RS485 六键开关 第5路 地址:2 ✓ 支持
742
743
  | 通用Modbus | 各类标准Modbus设备 |
743
744
  | 自定义协议 | 任意485码匹配(开关/窗帘/场景) |
744
745
 
745
- ### 自定义协议模式
746
+ ### 自定义协议模式(v1.7.0增强)
747
+
748
+ 当内置协议无法满足需求时,可使用"自定义协议"模式,手动录入RS485十六进制码进行双向匹配。
749
+
750
+ #### 自定义开关(4组码)
751
+
752
+ - **发开码**:Mesh开关打开时,发送此码到RS485总线
753
+ - **发关码**:Mesh开关关闭时,发送此码到RS485总线
754
+ - **收开码**:RS485总线收到此码时,触发Mesh开关打开
755
+ - **收关码**:RS485总线收到此码时,触发Mesh开关关闭
746
756
 
747
- 当内置协议无法满足需求时,可使用"自定义协议"模式,手动录入RS485十六进制码进行双向匹配:
757
+ **翻转模式**:当收开码=收关码时,收到后自动翻转开关状态
748
758
 
749
- #### 自定义开关
750
- - **打开码**:当Mesh开关打开时发送此码,收到此码时触发Mesh开关打开
751
- - **关闭码**:当Mesh开关关闭时发送此码,收到此码时触发Mesh开关关闭
759
+ #### 自定义窗帘(6组码)
752
760
 
753
- #### 自定义窗帘
754
- - **打开码**:Mesh窗帘打开 ↔ RS485打开命令
755
- - **关闭码**:Mesh窗帘关闭 ↔ RS485关闭命令
756
- - **停止码**:Mesh窗帘停止 ↔ RS485停止命令
761
+ - **发开码**、**发关码**、**发停码**:Mesh→RS485
762
+ - **收开码**、**收关码**、**收停码**:RS485→Mesh
763
+
764
+ #### 自定义空调(完整收发码)
765
+
766
+ - **开关**:发开码、发关码、收开码、收关码
767
+ - **风速**:发高风、发中风、发低风、收高风、收中风、收低风
768
+ - **模式**:发制冷、发制热、发除湿、发送风、收制冷、收制热、收除湿、收送风
769
+ - **温度**:发温度码、收温度码(支持16-30度,使用{TEMP}占位符)
757
770
 
758
771
  #### 配置示例
772
+
773
+ **自定义开关(花语前湾大屏)**:
774
+ ```
775
+ 品牌: 自定义协议
776
+ 类型: 自定义开关
777
+ 发开码: 01 20 10 14 00 01 00 7F 82 B6
778
+ 发关码: 01 20 10 14 00 01 00 FF 83 16
779
+ 收开码: 01 20
780
+ 收关码: 01 20
781
+ ```
782
+ 说明:收开码=收关码,收到后翻转开关状态
783
+
784
+ **自定义窗帘**:
759
785
  ```
760
786
  品牌: 自定义协议
761
787
  类型: 自定义窗帘
762
- 打开码: 55 02 02 03 01 49 44
763
- 关闭码: 55 02 02 03 02 09 45
764
- 停止码: 55 02 02 03 03 C8 85
788
+ 发开码: 55 02 02 03 01 49 44
789
+ 发关码: 55 02 02 03 02 09 45
790
+ 发停码: 55 02 02 03 03 C8 85
791
+ 收开码: 55 02 02 03 01
792
+ 收关码: 55 02 02 03 02
793
+ 收停码: 55 02 02 03 03
765
794
  ```
766
795
 
767
796
  #### 工作原理
768
- 1. **Mesh→RS485**:Mesh设备状态变化时,发送对应的自定义码
769
- 2. **RS485→Mesh**:收到的RS485帧与自定义码匹配,触发对应Mesh设备
770
- 3. **防死循环**:500ms内不重复触发,避免Mesh→RS485→Mesh循环
797
+ 1. **Mesh→RS485**:Mesh设备状态变化时,发送对应的发码
798
+ 2. **RS485→Mesh**:收到的RS485帧与收码匹配,触发对应Mesh设备
799
+ 3. **翻转逻辑**:开关的收开码=收关码时,收到后翻转当前状态
800
+ 4. **防死循环**:500ms内不重复触发,避免Mesh→RS485→Mesh循环
771
801
 
772
- > 自定义码支持任意长度的十六进制字符串,支持空格分隔(如 `55 01 01` 或 `550101`)
802
+ #### 注意事项
803
+ - 支持最多24字节(72个十六进制字符)数据录入
804
+ - 支持空格分隔(如 `55 01 01` 或 `550101`)
805
+ - 自定义模式不需要填写地址字段(自动隐藏)
806
+ - 配置持久化保存,重启后保持
773
807
 
774
808
  ### 注意事项
775
809
 
@@ -1221,6 +1255,42 @@ node-red-contrib-symi-mesh/
1221
1255
 
1222
1256
  ## 更新日志
1223
1257
 
1258
+ ### v1.7.1 (2025-12-21)
1259
+ - **自定义协议全面修复**:完善自定义开关/窗帘/空调双向同步
1260
+ - **空调风速控制**:修复风速变化事件触发机制
1261
+ - 添加fanMode字段支持(mesh空调实际使用的字段)
1262
+ - 修复DeviceInfo事件触发,正确发送device-state-changed事件
1263
+ - 支持风速值1-4(1=高, 2=中, 3=低, 4=自动)
1264
+ - 自动识别温控器0x02消息中的风速控制
1265
+ - **空调开关控制**:修复开关状态变化事件触发
1266
+ - 温控器0x02开关消息正确触发device-state-changed事件
1267
+ - 自定义空调开关码(acSendOn/acSendOff)正确发送到RS485总线
1268
+ - **RS485收码匹配**:修复hexStr格式处理
1269
+ - hexStr完全去掉空格,确保标准格式(如030610330001BD27)
1270
+ - 用户录入支持带空格格式(如01 06 10 34 00 01 0D 04)
1271
+ - 自动匹配并触发mesh实体动作
1272
+ - **事件系统优化**:
1273
+ - DeviceInfo添加manager引用,正确触发事件
1274
+ - 修复事件名称不匹配问题(stateChange -> device-state-changed)
1275
+ - 确保所有自定义码双向同步正常
1276
+ - **队列处理**:
1277
+ - 命令队列顺序处理,防止并发冲突
1278
+ - 500ms防死循环机制
1279
+ - 队列限制100条,防止内存溢出
1280
+ - **调试日志**:添加详细调试日志,方便排查问题
1281
+
1282
+ ### v1.7.0 (2025-12-21)
1283
+ - **自定义协议增强**:RS485桥接节点自定义协议功能全面升级
1284
+ - 自定义开关:添加发开、发关、收开、收关4组码
1285
+ - 翻转模式:收开码=收关码时,收到后自动翻转开关状态
1286
+ - 自定义窗帘:添加发开、发关、发停、收开、收关、收停6组码
1287
+ - 自定义空调:新增完整收发码支持(开关、风速、模式、温度)
1288
+ - 支持最多24字节(72个十六进制字符)数据录入
1289
+ - 自定义模式自动隐藏地址字段
1290
+ - 配置持久化保存,重启后保持
1291
+ - 防死循环机制优化
1292
+ - 内存优化,防止日志溢出
1293
+
1224
1294
  ### v1.6.9 (2025-12-20)
1225
1295
  - **KNX-HA双向同步**:新增`symi-knx-ha-bridge`节点
1226
1296
  - 直接连接KNX与HA实体,实现双向同步
@@ -1269,34 +1339,6 @@ node-red-contrib-symi-mesh/
1269
1339
  - 多设备类型:开关、调光灯、窗帘、空调、新风、地暖
1270
1340
  - 智能通道选择:根据Mesh设备实际路数显示可选通道
1271
1341
 
1272
- ### v1.6.6 (2025-12-09)
1273
- - **三合一面板完整双向同步**:支持空调+新风+地暖独立RS485映射
1274
- - 三合一0x94协议完整解析:空调(开关/模式/风速/温度)、地暖(开关/温度)、新风(开关/风速)
1275
- - Mesh→RS485:三合一状态变化自动发送对应RS485帧
1276
- - RS485→Mesh:RS485帧自动同步到三合一面板对应功能
1277
- - 支持climateSwitch/climateMode/fanMode/floorHeatingSwitch/freshAirSwitch等字段
1278
- - **开关双向同步修复**:修复米家App全开/全关只触发部分按键的问题
1279
- - 命令队列防抖逻辑修复:只有完全相同映射才合并,不同映射独立处理
1280
- - 支持同一Mesh设备多通道分别绑定不同RS485设备
1281
- - **RS485多映射匹配**:修复同一从机地址多个映射时只匹配第一个的问题
1282
- - findAllRS485Mappings返回所有匹配的映射
1283
- - rs485Channel精确匹配:根据寄存器地址确定通道
1284
- - **网络异常处理增强**:彻底修复Node.js 18+ AggregateError导致崩溃的问题
1285
- - 全局uncaughtException/unhandledRejection处理器
1286
- - TCP连接过程同步错误捕获
1287
- - 只捕获网络相关错误,不影响其他异常
1288
- - **三合一面板自动识别**:设备列表API返回isThreeInOne标志
1289
- - **窗帘双协议兼容**:同时支持米家和小程序两种控制协议
1290
- - 米家协议: `subOpcode=0x06, status: 0=打开中, 1=关闭中, 2=停止`
1291
- - 小程序协议: `subOpcode=0x05, status: 1=打开, 2=关闭, 3=停止`
1292
- - **话语前湾协议完善**:
1293
- - 空调:客厅/主卧/次卧1/次卧2,寄存器0x0FA0-0x0FAF
1294
- - 地暖:客餐厅(60)/主卧(61)/次卧1(62)/次卧2(63),寄存器0x0039/0x0043
1295
- - 新风:从机60,开关0x0039(开=1),风速0x004B(高=2,低=0)
1296
- - **地址输入修复**:允许从机地址为0(话语前湾空调地址是0)
1297
- - **内存安全**:命令队列限制100条,节点关闭时清理所有缓存
1298
- - **文档更新**:添加三合一面板RS485配置说明和协议对照表
1299
-
1300
1342
  ## 许可证
1301
1343
 
1302
1344
  MIT License
@@ -1308,8 +1350,8 @@ Copyright (c) 2025 SYMI 亖米
1308
1350
  ## 关于
1309
1351
 
1310
1352
  **作者**: SYMI 亖米
1311
- **版本**: 1.6.9
1353
+ **版本**: 1.7.1
1312
1354
  **协议**: 蓝牙MESH网关(初级版)串口协议V1.0
1313
- **最后更新**: 2025-12-20
1355
+ **最后更新**: 2025-12-21
1314
1356
  **仓库**: https://github.com/symi-daguo/node-red-contrib-symi-mesh
1315
1357
  **npm包**: https://www.npmjs.com/package/node-red-contrib-symi-mesh
@@ -13,7 +13,7 @@ const DEVICE_TYPE_NAMES = {
13
13
  };
14
14
 
15
15
  class DeviceInfo {
16
- constructor(data) {
16
+ constructor(data, manager) {
17
17
  this.macAddress = data.macAddress;
18
18
  this.networkAddress = data.networkAddress;
19
19
  this.deviceType = data.deviceType;
@@ -25,6 +25,7 @@ class DeviceInfo {
25
25
  this.state = {};
26
26
  this.lastSeen = Date.now();
27
27
  this.isThreeInOne = false;
28
+ this.manager = manager; // 保存DeviceManager引用
28
29
  }
29
30
 
30
31
  generateName() {
@@ -199,7 +200,16 @@ class DeviceInfo {
199
200
  case 0x1C:
200
201
  // 风速设置(温控器和三合一空调都使用)
201
202
  if (parameters.length > 0) {
203
+ const oldFanMode = this.state.fanMode;
202
204
  this.state.fanMode = parameters[0];
205
+ // 触发状态变化事件
206
+ if (oldFanMode !== this.state.fanMode) {
207
+ this.emit('stateChange', {
208
+ device: this,
209
+ state: { fanMode: this.state.fanMode },
210
+ attrType: attrType
211
+ });
212
+ }
203
213
  }
204
214
  break;
205
215
  case 0x1D:
@@ -316,8 +326,10 @@ class DeviceInfo {
316
326
  }
317
327
 
318
328
  // byte2: bit[0-2]=空调风速 (协议值: 0=自动, 1=低, 2=中, 4=高)
329
+ // 统一转换为标准Mesh值: 1=高, 2=中, 3=低, 4=自动
319
330
  const fanLevel = byte2 & 0x07;
320
- this.state.fanMode = fanLevel;
331
+ const fanMap = { 0: 4, 1: 3, 2: 2, 4: 1 };
332
+ this.state.fanMode = fanMap[fanLevel] || 3; // 默认低风
321
333
 
322
334
  // byte3: 空调设定温度 (16-30°C)
323
335
  if (byte3 >= 16 && byte3 <= 30) {
@@ -353,8 +365,10 @@ class DeviceInfo {
353
365
  this.state.freshAirMode = freshMode;
354
366
 
355
367
  // byte7: bit[0-2]=新风风速 (协议值: 0=自动, 1=低, 2=中, 4=高)
368
+ // 统一转换为标准Mesh值: 1=高, 2=中, 3=低, 4=自动
356
369
  const freshFan = byte7 & 0x07;
357
- this.state.freshAirSpeed = freshFan;
370
+ const freshFanMap = { 0: 4, 1: 3, 2: 2, 4: 1 };
371
+ this.state.freshAirSpeed = freshFanMap[freshFan] || 4; // 默认自动
358
372
 
359
373
  }
360
374
 
@@ -385,7 +399,44 @@ class DeviceInfo {
385
399
  if (this.channels === 1) {
386
400
  // 单路开关
387
401
  const value = parameters[0];
388
- this.state.switch = value === 0x02;
402
+ console.log(`[DeviceManager] handleSwitchState: deviceType=${this.deviceType}, channels=${this.channels}, value=${value}, name=${this.name}`);
403
+ // 温控器设备:0x02消息可能是开关或风速
404
+ if (this.deviceType === 10) {
405
+ console.log(`[DeviceManager] 温控器设备,检查风速: value=${value}`);
406
+ // 如果值是1-4,是风速控制
407
+ if (value >= 1 && value <= 4) {
408
+ const oldFanMode = this.state.fanMode;
409
+ this.state.fanMode = value;
410
+ console.log(`[DeviceManager] 风速变化: ${oldFanMode} -> ${this.state.fanMode}`);
411
+ // 总是触发事件,即使值没变
412
+ console.log(`[DeviceManager] 触发fanMode事件`);
413
+ // 注意:这里不能直接emit,因为这是DeviceInfo实例,不是DeviceManager
414
+ // 需要通过DeviceManager来触发事件
415
+ if (this.manager) {
416
+ this.manager.emit('device-state-changed', {
417
+ device: this,
418
+ state: { fanMode: this.state.fanMode },
419
+ attrType: 0x1C
420
+ });
421
+ }
422
+ } else {
423
+ // 否则是开关控制
424
+ console.log(`[DeviceManager] 开关控制: value=${value}`);
425
+ const oldSwitch = this.state.switch;
426
+ this.state.switch = value === 0x02;
427
+ // 触发开关状态变化事件
428
+ if (oldSwitch !== this.state.switch && this.manager) {
429
+ console.log(`[DeviceManager] 触发switch事件: ${oldSwitch} -> ${this.state.switch}`);
430
+ this.manager.emit('device-state-changed', {
431
+ device: this,
432
+ state: { switch: this.state.switch },
433
+ attrType: 0x02
434
+ });
435
+ }
436
+ }
437
+ } else {
438
+ this.state.switch = value === 0x02;
439
+ }
389
440
  } else if (this.channels <= 4) {
390
441
  // 1-4路开关:通常1字节,但米家/面板操作可能发送2字节(0x45类型)
391
442
  let value;
@@ -601,7 +652,7 @@ class DeviceManager extends EventEmitter {
601
652
  let nameChanged = false;
602
653
 
603
654
  if (!device) {
604
- device = new DeviceInfo(deviceData);
655
+ device = new DeviceInfo(deviceData, this);
605
656
  this.devices.set(mac, device);
606
657
  isNew = true;
607
658
  } else {
@@ -160,9 +160,11 @@
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字节地址
163
+ // 地址输入框:杜亚2字节地址,自定义模式隐藏,其他显示
164
164
  var addrHtml = '';
165
- if (m.brand === 'duya') {
165
+ if (m.brand === 'custom') {
166
+ addrHtml = '<div class="addr-col" style="width:60px;visibility:hidden;"></div>';
167
+ } else if (m.brand === 'duya') {
166
168
  addrHtml = '<div class="addr-col duya-addr" style="display:flex;gap:2px;">' +
167
169
  '<input type="number" class="addr-high" value="' + (m.addrHigh || 1) + '" min="0" max="255" style="width:40px;" title="地址高字节" placeholder="高">' +
168
170
  '<input type="number" class="addr-low" value="' + (m.addrLow || 1) + '" min="0" max="255" style="width:40px;" title="地址低字节" placeholder="低">' +
@@ -279,33 +281,88 @@
279
281
  renderMappings();
280
282
  });
281
283
 
282
- // 自定义码输入事件
283
- container.find('.custom-code-on').off('change').on('change', function() {
284
+ // 自定义码输入事件 - 开关
285
+ container.find('.custom-send-on').off('change').on('change', function() {
284
286
  var idx = $(this).closest('.mapping-row').data('idx');
285
287
  mappings[idx].customCodes = mappings[idx].customCodes || {};
286
- mappings[idx].customCodes.on = $(this).val();
288
+ mappings[idx].customCodes.sendOn = $(this).val();
287
289
  });
288
- container.find('.custom-code-off').off('change').on('change', function() {
290
+ container.find('.custom-send-off').off('change').on('change', function() {
289
291
  var idx = $(this).closest('.mapping-row').data('idx');
290
292
  mappings[idx].customCodes = mappings[idx].customCodes || {};
291
- mappings[idx].customCodes.off = $(this).val();
293
+ mappings[idx].customCodes.sendOff = $(this).val();
292
294
  });
293
- container.find('.custom-code-open').off('change').on('change', function() {
295
+ container.find('.custom-recv-on').off('change').on('change', function() {
294
296
  var idx = $(this).closest('.mapping-row').data('idx');
295
297
  mappings[idx].customCodes = mappings[idx].customCodes || {};
296
- mappings[idx].customCodes.open = $(this).val();
298
+ mappings[idx].customCodes.recvOn = $(this).val();
297
299
  });
298
- container.find('.custom-code-close').off('change').on('change', function() {
300
+ container.find('.custom-recv-off').off('change').on('change', function() {
299
301
  var idx = $(this).closest('.mapping-row').data('idx');
300
302
  mappings[idx].customCodes = mappings[idx].customCodes || {};
301
- mappings[idx].customCodes.close = $(this).val();
303
+ mappings[idx].customCodes.recvOff = $(this).val();
302
304
  });
303
- container.find('.custom-code-stop').off('change').on('change', function() {
305
+ // 窗帘码
306
+ container.find('.custom-send-open, .custom-send-close, .custom-send-stop, .custom-recv-open, .custom-recv-close, .custom-recv-stop').off('change').on('change', function() {
304
307
  var idx = $(this).closest('.mapping-row').data('idx');
305
308
  mappings[idx].customCodes = mappings[idx].customCodes || {};
306
- mappings[idx].customCodes.stop = $(this).val();
309
+ if ($(this).hasClass('custom-send-open')) mappings[idx].customCodes.sendOpen = $(this).val();
310
+ if ($(this).hasClass('custom-send-close')) mappings[idx].customCodes.sendClose = $(this).val();
311
+ if ($(this).hasClass('custom-send-stop')) mappings[idx].customCodes.sendStop = $(this).val();
312
+ if ($(this).hasClass('custom-recv-open')) mappings[idx].customCodes.recvOpen = $(this).val();
313
+ if ($(this).hasClass('custom-recv-close')) mappings[idx].customCodes.recvClose = $(this).val();
314
+ if ($(this).hasClass('custom-recv-stop')) mappings[idx].customCodes.recvStop = $(this).val();
307
315
  });
308
- container.find('.custom-code-trigger').off('change').on('change', function() {
316
+ // 空调码
317
+ container.find('[class^="custom-ac-"], [class^="custom-fan-"], [class^="custom-mode-"], [class^="custom-temp-"]').off('change').on('change', function() {
318
+ var idx = $(this).closest('.mapping-row').data('idx');
319
+ mappings[idx].customCodes = mappings[idx].customCodes || {};
320
+ var cls = $(this).attr('class').split(' ')[0];
321
+
322
+ // 处理空调开关、风速、模式
323
+ if (cls.includes('ac-send-on')) mappings[idx].customCodes.acSendOn = $(this).val();
324
+ else if (cls.includes('ac-send-off')) mappings[idx].customCodes.acSendOff = $(this).val();
325
+ else if (cls.includes('ac-recv-on')) mappings[idx].customCodes.acRecvOn = $(this).val();
326
+ else if (cls.includes('ac-recv-off')) mappings[idx].customCodes.acRecvOff = $(this).val();
327
+ else if (cls.includes('fan-send-high')) mappings[idx].customCodes.fanSendHigh = $(this).val();
328
+ else if (cls.includes('fan-send-mid')) mappings[idx].customCodes.fanSendMid = $(this).val();
329
+ else if (cls.includes('fan-send-low')) mappings[idx].customCodes.fanSendLow = $(this).val();
330
+ else if (cls.includes('fan-recv-high')) mappings[idx].customCodes.fanRecvHigh = $(this).val();
331
+ else if (cls.includes('fan-recv-mid')) mappings[idx].customCodes.fanRecvMid = $(this).val();
332
+ else if (cls.includes('fan-recv-low')) mappings[idx].customCodes.fanRecvLow = $(this).val();
333
+ else if (cls.includes('mode-send-cool')) mappings[idx].customCodes.modeSendCool = $(this).val();
334
+ else if (cls.includes('mode-send-heat')) mappings[idx].customCodes.modeSendHeat = $(this).val();
335
+ else if (cls.includes('mode-send-dry')) mappings[idx].customCodes.modeSendDry = $(this).val();
336
+ else if (cls.includes('mode-send-fan')) mappings[idx].customCodes.modeSendFan = $(this).val();
337
+ else if (cls.includes('mode-recv-cool')) mappings[idx].customCodes.modeRecvCool = $(this).val();
338
+ else if (cls.includes('mode-recv-heat')) mappings[idx].customCodes.modeRecvHeat = $(this).val();
339
+ else if (cls.includes('mode-recv-dry')) mappings[idx].customCodes.modeRecvDry = $(this).val();
340
+ else if (cls.includes('mode-recv-fan')) mappings[idx].customCodes.modeRecvFan = $(this).val();
341
+
342
+ // 处理 16-30 度独立温度码
343
+ else if (cls.startsWith('custom-temp-')) {
344
+ // 提取 key, 如 tempSendCool16
345
+ var parts = cls.split('-'); // ["custom", "temp", "send", "cool", "16"]
346
+ if (parts.length === 5) {
347
+ var type = parts[2]; // send/recv
348
+ var mode = parts[3]; // cool/heat
349
+ var temp = parts[4]; // 16-30
350
+ var key = 'temp' + type.charAt(0).toUpperCase() + type.slice(1) +
351
+ mode.charAt(0).toUpperCase() + mode.slice(1) + temp;
352
+ mappings[idx].customCodes[key] = $(this).val();
353
+ } else {
354
+ // 兼容旧的占位符模式
355
+ if (cls.includes('temp-send-cool')) mappings[idx].customCodes.tempSendCool = $(this).val();
356
+ else if (cls.includes('temp-recv-cool')) mappings[idx].customCodes.tempRecvCool = $(this).val();
357
+ else if (cls.includes('temp-send-heat')) mappings[idx].customCodes.tempSendHeat = $(this).val();
358
+ else if (cls.includes('temp-recv-heat')) mappings[idx].customCodes.tempRecvHeat = $(this).val();
359
+ else if (cls.includes('temp-send')) mappings[idx].customCodes.tempSend = $(this).val();
360
+ else if (cls.includes('temp-recv')) mappings[idx].customCodes.tempRecv = $(this).val();
361
+ }
362
+ }
363
+ });
364
+ // 场景码
365
+ container.find('.custom-trigger').off('change').on('change', function() {
309
366
  var idx = $(this).closest('.mapping-row').data('idx');
310
367
  mappings[idx].customCodes = mappings[idx].customCodes || {};
311
368
  mappings[idx].customCodes.trigger = $(this).val();
@@ -322,15 +379,79 @@
322
379
  html += '<div style="font-size:11px;color:#f57c00;margin-bottom:6px;"><i class="fa fa-code"></i> 自定义RS485码(十六进制,如: 55 01 01 03 01 B9 00)</div>';
323
380
 
324
381
  if (deviceId === 'custom_switch') {
325
- html += '<div style="display:flex;gap:8px;flex-wrap:wrap;">';
326
- html += '<label style="font-size:11px;">打开码: <input type="text" class="custom-code-on" value="' + (codes.on || '') + '" placeholder="55 01 01..." style="width:150px;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:11px;font-family:monospace;"></label>';
327
- html += '<label style="font-size:11px;">关闭码: <input type="text" class="custom-code-off" value="' + (codes.off || '') + '" placeholder="55 01 02..." style="width:150px;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:11px;font-family:monospace;"></label>';
382
+ html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;">';
383
+ html += '<label style="font-size:11px;">发开码: <input type="text" class="custom-code-send-on" value="' + (codes.sendOn || '') + '" placeholder="01 20 10 14 00 01 00 7F..." maxlength="72" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:10px;font-family:monospace;"></label>';
384
+ html += '<label style="font-size:11px;">发关码: <input type="text" class="custom-code-send-off" value="' + (codes.sendOff || '') + '" placeholder="01 20 10 14 00 01 00 FF..." maxlength="72" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:10px;font-family:monospace;"></label>';
385
+ html += '<label style="font-size:11px;">收开码: <input type="text" class="custom-code-recv-on" value="' + (codes.recvOn || '') + '" placeholder="01 20..." maxlength="72" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:10px;font-family:monospace;"></label>';
386
+ html += '<label style="font-size:11px;">收关码: <input type="text" class="custom-code-recv-off" value="' + (codes.recvOff || '') + '" placeholder="01 20..." maxlength="72" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:10px;font-family:monospace;"></label>';
328
387
  html += '</div>';
388
+ html += '<div style="font-size:10px;color:#666;margin-top:4px;">提示:收开码=收关码时,收到后翻转开关状态</div>';
329
389
  } else if (deviceId === 'custom_curtain') {
330
- html += '<div style="display:flex;gap:8px;flex-wrap:wrap;">';
331
- html += '<label style="font-size:11px;">打开码: <input type="text" class="custom-code-open" value="' + (codes.open || '') + '" placeholder="55 02 02 03 01..." style="width:150px;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:11px;font-family:monospace;"></label>';
332
- html += '<label style="font-size:11px;">关闭码: <input type="text" class="custom-code-close" value="' + (codes.close || '') + '" placeholder="55 02 02 03 02..." style="width:150px;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:11px;font-family:monospace;"></label>';
333
- html += '<label style="font-size:11px;">停止码: <input type="text" class="custom-code-stop" value="' + (codes.stop || '') + '" placeholder="55 02 02 03 03..." style="width:150px;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:11px;font-family:monospace;"></label>';
390
+ html += '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;">';
391
+ html += '<label style="font-size:11px;">发开码: <input type="text" class="custom-code-send-open" value="' + (codes.sendOpen || '') + '" placeholder="55 02 02 03 01..." maxlength="72" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:10px;font-family:monospace;"></label>';
392
+ html += '<label style="font-size:11px;">发关码: <input type="text" class="custom-code-send-close" value="' + (codes.sendClose || '') + '" placeholder="55 02 02 03 02..." maxlength="72" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:10px;font-family:monospace;"></label>';
393
+ html += '<label style="font-size:11px;">发停码: <input type="text" class="custom-code-send-stop" value="' + (codes.sendStop || '') + '" placeholder="55 02 02 03 03..." maxlength="72" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:10px;font-family:monospace;"></label>';
394
+ html += '<label style="font-size:11px;">收开码: <input type="text" class="custom-code-recv-open" value="' + (codes.recvOpen || '') + '" placeholder="01 20..." maxlength="72" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:10px;font-family:monospace;"></label>';
395
+ html += '<label style="font-size:11px;">收关码: <input type="text" class="custom-code-recv-close" value="' + (codes.recvClose || '') + '" placeholder="01 20..." maxlength="72" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:10px;font-family:monospace;"></label>';
396
+ html += '<label style="font-size:11px;">收停码: <input type="text" class="custom-code-recv-stop" value="' + (codes.recvStop || '') + '" placeholder="01 20..." maxlength="72" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:10px;font-family:monospace;"></label>';
397
+ html += '</div>';
398
+ } else if (deviceId === 'custom_climate') {
399
+ html += '<div style="font-size:10px;color:#f57c00;margin-bottom:4px;">开关控制</div>';
400
+ html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-bottom:6px;">';
401
+ html += '<label style="font-size:10px;">发开码: <input type="text" class="custom-ac-send-on" value="' + (codes.acSendOn || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
402
+ html += '<label style="font-size:10px;">发关码: <input type="text" class="custom-ac-send-off" value="' + (codes.acSendOff || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
403
+ html += '<label style="font-size:10px;">收开码: <input type="text" class="custom-ac-recv-on" value="' + (codes.acRecvOn || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
404
+ html += '<label style="font-size:10px;">收关码: <input type="text" class="custom-ac-recv-off" value="' + (codes.acRecvOff || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
405
+ html += '</div>';
406
+
407
+ html += '<div style="font-size:10px;color:#f57c00;margin-bottom:4px;">风速控制</div>';
408
+ html += '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;margin-bottom:6px;">';
409
+ html += '<label style="font-size:10px;">发高风: <input type="text" class="custom-fan-send-high" value="' + (codes.fanSendHigh || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
410
+ html += '<label style="font-size:10px;">发中风: <input type="text" class="custom-fan-send-mid" value="' + (codes.fanSendMid || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
411
+ html += '<label style="font-size:10px;">发低风: <input type="text" class="custom-fan-send-low" value="' + (codes.fanSendLow || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
412
+ html += '<label style="font-size:10px;">收高风: <input type="text" class="custom-fan-recv-high" value="' + (codes.fanRecvHigh || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
413
+ html += '<label style="font-size:10px;">收中风: <input type="text" class="custom-fan-recv-mid" value="' + (codes.fanRecvMid || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
414
+ html += '<label style="font-size:10px;">收低风: <input type="text" class="custom-fan-recv-low" value="' + (codes.fanRecvLow || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
415
+ html += '</div>';
416
+
417
+ html += '<div style="font-size:10px;color:#f57c00;margin-bottom:4px;">模式控制</div>';
418
+ html += '<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:4px;margin-bottom:6px;">';
419
+ html += '<label style="font-size:10px;">发制冷: <input type="text" class="custom-mode-send-cool" value="' + (codes.modeSendCool || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
420
+ html += '<label style="font-size:10px;">发制热: <input type="text" class="custom-mode-send-heat" value="' + (codes.modeSendHeat || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
421
+ html += '<label style="font-size:10px;">发除湿: <input type="text" class="custom-mode-send-dry" value="' + (codes.modeSendDry || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
422
+ html += '<label style="font-size:10px;">发送风: <input type="text" class="custom-mode-send-fan" value="' + (codes.modeSendFan || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
423
+ html += '<label style="font-size:10px;">收制冷: <input type="text" class="custom-mode-recv-cool" value="' + (codes.modeRecvCool || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
424
+ html += '<label style="font-size:10px;">收制热: <input type="text" class="custom-mode-recv-heat" value="' + (codes.modeRecvHeat || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
425
+ html += '<label style="font-size:10px;">收除湿: <input type="text" class="custom-mode-recv-dry" value="' + (codes.modeRecvDry || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
426
+ html += '<label style="font-size:10px;">收送风: <input type="text" class="custom-mode-recv-fan" value="' + (codes.modeRecvFan || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
427
+ html += '</div>';
428
+
429
+ html += '<div style="font-size:10px;color:#f57c00;margin-bottom:4px;margin-top:4px;">制冷温度发码 (16-30度)</div>';
430
+ html += '<div style="display:grid;grid-template-columns:repeat(5, 1fr);gap:4px;margin-bottom:6px;">';
431
+ for (var t=16; t<=30; t++) {
432
+ html += '<label style="font-size:9px;">' + t + '°C: <input type="text" class="custom-temp-send-cool-' + t + '" value="' + (codes['tempSendCool'+t] || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
433
+ }
434
+ html += '</div>';
435
+
436
+ html += '<div style="font-size:10px;color:#f57c00;margin-bottom:4px;">制冷温度收码 (16-30度)</div>';
437
+ html += '<div style="display:grid;grid-template-columns:repeat(5, 1fr);gap:4px;margin-bottom:6px;">';
438
+ for (var t=16; t<=30; t++) {
439
+ html += '<label style="font-size:9px;">' + t + '°C: <input type="text" class="custom-temp-recv-cool-' + t + '" value="' + (codes['tempRecvCool'+t] || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
440
+ }
441
+ html += '</div>';
442
+
443
+ html += '<div style="font-size:10px;color:#f57c00;margin-bottom:4px;">制热温度发码 (16-30度)</div>';
444
+ html += '<div style="display:grid;grid-template-columns:repeat(5, 1fr);gap:4px;margin-bottom:6px;">';
445
+ for (var t=16; t<=30; t++) {
446
+ html += '<label style="font-size:9px;">' + t + '°C: <input type="text" class="custom-temp-send-heat-' + t + '" value="' + (codes['tempSendHeat'+t] || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
447
+ }
448
+ html += '</div>';
449
+
450
+ html += '<div style="font-size:10px;color:#f57c00;margin-bottom:4px;">制热温度收码 (16-30度)</div>';
451
+ html += '<div style="display:grid;grid-template-columns:repeat(5, 1fr);gap:4px;margin-bottom:6px;">';
452
+ for (var t=16; t<=30; t++) {
453
+ html += '<label style="font-size:9px;">' + t + '°C: <input type="text" class="custom-temp-recv-heat-' + t + '" value="' + (codes['tempRecvHeat'+t] || '') + '" placeholder="" maxlength="128" style="width:100%;padding:2px 4px;border:1px solid #ffb74d;border-radius:2px;font-size:9px;font-family:monospace;"></label>';
454
+ }
334
455
  html += '</div>';
335
456
  } else if (deviceId === 'custom_scene') {
336
457
  html += '<div style="display:flex;gap:8px;">';
@@ -374,18 +495,71 @@
374
495
  }
375
496
  // 保存自定义码
376
497
  if (m.brand === 'custom') {
498
+ var row = $(this);
377
499
  m.customCodes = {};
378
- var on = $(this).find('.custom-code-on').val();
379
- var off = $(this).find('.custom-code-off').val();
380
- var open = $(this).find('.custom-code-open').val();
381
- var close = $(this).find('.custom-code-close').val();
382
- var stop = $(this).find('.custom-code-stop').val();
383
- var trigger = $(this).find('.custom-code-trigger').val();
384
- if (on) m.customCodes.on = on;
385
- if (off) m.customCodes.off = off;
386
- if (open) m.customCodes.open = open;
387
- if (close) m.customCodes.close = close;
388
- if (stop) m.customCodes.stop = stop;
500
+
501
+ // 通用/开关
502
+ var sendOn = row.find('.custom-code-send-on').val() || row.find('.custom-ac-send-on').val() || row.find('.custom-send-on').val();
503
+ var sendOff = row.find('.custom-code-send-off').val() || row.find('.custom-ac-send-off').val() || row.find('.custom-send-off').val();
504
+ var recvOn = row.find('.custom-code-recv-on').val() || row.find('.custom-ac-recv-on').val() || row.find('.custom-recv-on').val();
505
+ var recvOff = row.find('.custom-code-recv-off').val() || row.find('.custom-ac-recv-off').val() || row.find('.custom-recv-off').val();
506
+
507
+ if (sendOn) m.customCodes.sendOn = sendOn;
508
+ if (sendOff) m.customCodes.sendOff = sendOff;
509
+ if (recvOn) m.customCodes.recvOn = recvOn;
510
+ if (recvOff) m.customCodes.recvOff = recvOff;
511
+
512
+ // 兼容旧字段名 (空调开关)
513
+ if (sendOn) m.customCodes.acSendOn = sendOn;
514
+ if (sendOff) m.customCodes.acSendOff = sendOff;
515
+ if (recvOn) m.customCodes.acRecvOn = recvOn;
516
+ if (recvOff) m.customCodes.acRecvOff = recvOff;
517
+
518
+ // 窗帘
519
+ var sendOpen = row.find('.custom-code-send-open').val() || row.find('.custom-send-open').val();
520
+ var sendClose = row.find('.custom-code-send-close').val() || row.find('.custom-send-close').val();
521
+ var sendStop = row.find('.custom-code-send-stop').val() || row.find('.custom-send-stop').val();
522
+ var recvOpen = row.find('.custom-code-recv-open').val() || row.find('.custom-recv-open').val();
523
+ var recvClose = row.find('.custom-code-recv-close').val() || row.find('.custom-recv-close').val();
524
+ var recvStop = row.find('.custom-code-recv-stop').val() || row.find('.custom-recv-stop').val();
525
+
526
+ if (sendOpen) m.customCodes.sendOpen = sendOpen;
527
+ if (sendClose) m.customCodes.sendClose = sendClose;
528
+ if (sendStop) m.customCodes.sendStop = sendStop;
529
+ if (recvOpen) m.customCodes.recvOpen = recvOpen;
530
+ if (recvClose) m.customCodes.recvClose = recvClose;
531
+ if (recvStop) m.customCodes.recvStop = recvStop;
532
+
533
+ // 动态提取所有空调相关的自定义码 (ac-, fan-, mode-, temp-)
534
+ row.find('[class^="custom-ac-"], [class^="custom-fan-"], [class^="custom-mode-"], [class^="custom-temp-"]').each(function() {
535
+ var cls = $(this).attr('class').split(' ')[0];
536
+ var val = $(this).val();
537
+ if (!val) return;
538
+
539
+ if (cls.startsWith('custom-temp-')) {
540
+ var parts = cls.split('-');
541
+ if (parts.length === 5) {
542
+ // 独立温度码: custom-temp-send-cool-16
543
+ var type = parts[2];
544
+ var mode = parts[3];
545
+ var temp = parts[4];
546
+ var key = 'temp' + type.charAt(0).toUpperCase() + type.slice(1) +
547
+ mode.charAt(0).toUpperCase() + mode.slice(1) + temp;
548
+ m.customCodes[key] = val;
549
+ } else {
550
+ // 模板码: custom-temp-send-cool
551
+ var key = cls.replace('custom-', '').replace(/-([a-z])/g, function(g) { return g[1].toUpperCase(); });
552
+ m.customCodes[key] = val;
553
+ }
554
+ } else {
555
+ // 其他码: custom-ac-send-on -> acSendOn
556
+ var key = cls.replace('custom-', '').replace(/-([a-z])/g, function(g) { return g[1].toUpperCase(); });
557
+ m.customCodes[key] = val;
558
+ }
559
+ });
560
+
561
+ // 场景
562
+ var trigger = row.find('.custom-code-trigger').val() || row.find('.custom-trigger').val();
389
563
  if (trigger) m.customCodes.trigger = trigger;
390
564
  }
391
565
  mappings.push(m);
@@ -309,7 +309,11 @@ module.exports = function(RED) {
309
309
  name: '自定义窗帘',
310
310
  type: 'cover',
311
311
  customMode: true,
312
- // 用户需要在映射中配置: customCodes.open, customCodes.close, customCodes.stop
312
+ },
313
+ 'custom_climate': {
314
+ name: '自定义空调',
315
+ type: 'climate',
316
+ customMode: true,
313
317
  },
314
318
  'custom_scene': {
315
319
  name: '自定义场景',
@@ -520,13 +524,15 @@ module.exports = function(RED) {
520
524
 
521
525
  // 状态缓存 - 用于检测真正变化的开关
522
526
  node.stateCache = {};
527
+ // 空调模式缓存 - 用于温度控制时确定当前模式
528
+ node.climateCache = {};
523
529
  // 首次启动标记 - 跳过初始状态同步
524
530
  node.initializing = true;
525
531
  // 启动后延迟20秒再开始同步(Mesh网关需要15秒以上完成设备发现)
526
532
  setTimeout(() => {
527
533
  node.initializing = false;
528
534
  node.log('[RS485 Bridge] 初始化完成,开始同步');
529
- }, 20000);
535
+ }, 10000); // 10秒初始化延迟
530
536
 
531
537
  // Mesh设备状态变化处理(事件驱动)
532
538
  const handleMeshStateChange = (eventData) => {
@@ -708,13 +714,12 @@ module.exports = function(RED) {
708
714
  }).catch(err => {
709
715
  node.error(`[Mesh->杜亚] 位置同步失败: ${err.message}`);
710
716
  });
711
- }, 100); // 延迟100ms发送位置命令
717
+ }, 100);
712
718
  }
713
719
  continue;
714
720
  }
715
721
 
716
722
  // 【自定义窗帘】处理Mesh面板控制同步到485(逻辑同杜亚)
717
- // 必须检查isUserControl,只有NODE_ACK(subOpcode=0x05)才是用户控制
718
723
  if (mapping.brand === 'custom' && mapping.device === 'custom_curtain' && mapping.customCodes) {
719
724
  const now = Date.now();
720
725
  const curtainKey = `custom_curtain_${mac}`;
@@ -801,12 +806,22 @@ module.exports = function(RED) {
801
806
  );
802
807
  const hasACChange = isAC && (
803
808
  changed.targetTemp !== undefined ||
809
+ changed.acTargetTemp !== undefined ||
810
+ changed.temperature !== undefined ||
804
811
  changed.acMode !== undefined ||
812
+ changed.climateMode !== undefined ||
813
+ changed.mode !== undefined ||
805
814
  changed.acFanSpeed !== undefined ||
806
- // 三合一面板使用climate前缀
815
+ changed.fanSpeed !== undefined ||
816
+ changed.climateFanSpeed !== undefined ||
817
+ changed.fanMode !== undefined ||
818
+ changed.fan_speed !== undefined ||
819
+ changed.fanLevel !== undefined ||
820
+ changed.fan_level !== undefined ||
821
+ changed.speed !== undefined ||
822
+ changed.acSwitch !== undefined ||
807
823
  changed.climateSwitch !== undefined ||
808
- changed.climateMode !== undefined ||
809
- changed.fanMode !== undefined
824
+ changed.switch !== undefined
810
825
  );
811
826
  // 新风设备检测
812
827
  const isFreshAir = device.includes('fresh_air');
@@ -1169,8 +1184,8 @@ module.exports = function(RED) {
1169
1184
  // 开关类型
1170
1185
  if (mapping.device === 'custom_switch') {
1171
1186
  for (const [key, value] of Object.entries(state)) {
1172
- if (key === 'switch' || key === 'acSwitch') {
1173
- const hexCode = value ? codes.on : codes.off;
1187
+ if (key === 'switch' || key === 'acSwitch' || key.startsWith('switch_')) {
1188
+ const hexCode = value ? codes.sendOn : codes.sendOff;
1174
1189
  if (hexCode) {
1175
1190
  await node.sendCustomCode(hexCode);
1176
1191
  node.log(`[Mesh->自定义] 开关: ${value ? '开' : '关'}, 发送: ${hexCode}`);
@@ -1190,41 +1205,29 @@ module.exports = function(RED) {
1190
1205
  // 1. 优先检查动作命令
1191
1206
  if (state.curtainAction !== undefined || state.action !== undefined) {
1192
1207
  const action = state.curtainAction || state.action;
1193
- if (action === 1 || action === 'open') {
1194
- hexCode = codes.open;
1208
+ if (action === 1 || action === 'open' || action === 'opening') {
1209
+ hexCode = codes.sendOpen;
1195
1210
  actionName = '打开';
1196
- } else if (action === 2 || action === 'close') {
1197
- hexCode = codes.close;
1211
+ } else if (action === 2 || action === 'close' || action === 'closing') {
1212
+ hexCode = codes.sendClose;
1198
1213
  actionName = '关闭';
1199
- } else if (action === 3 || action === 'stop') {
1200
- hexCode = codes.stop;
1214
+ } else if (action === 3 || action === 'stop' || action === 'stopped') {
1215
+ hexCode = codes.sendStop;
1201
1216
  actionName = '停止';
1202
1217
  }
1203
1218
  }
1204
- // 2. 其次检查运行状态
1205
1219
  else if (state.curtainStatus !== undefined) {
1206
1220
  if (state.curtainStatus === 1) {
1207
- hexCode = codes.open;
1208
- actionName = '打开(运行中)';
1221
+ hexCode = codes.sendOpen;
1222
+ actionName = '打开';
1209
1223
  } else if (state.curtainStatus === 2) {
1210
- hexCode = codes.close;
1211
- actionName = '关闭(运行中)';
1212
- } else if (state.curtainStatus === 0 && codes.stop) {
1213
- // 0=已停止,发送停止码
1214
- hexCode = codes.stop;
1224
+ hexCode = codes.sendClose;
1225
+ actionName = '关闭';
1226
+ } else if (state.curtainStatus === 0 && codes.sendStop) {
1227
+ hexCode = codes.sendStop;
1215
1228
  actionName = '停止';
1216
1229
  }
1217
1230
  }
1218
- // 3. 最后检查位置(仅在极端位置时)
1219
- else if (state.curtainPosition !== undefined) {
1220
- if (state.curtainPosition >= 95) {
1221
- hexCode = codes.open;
1222
- actionName = '打开(位置>=95)';
1223
- } else if (state.curtainPosition <= 5) {
1224
- hexCode = codes.close;
1225
- actionName = '关闭(位置<=5)';
1226
- }
1227
- }
1228
1231
 
1229
1232
  if (hexCode) {
1230
1233
  // 防抖:500ms内不重复发送相同命令
@@ -1240,6 +1243,76 @@ module.exports = function(RED) {
1240
1243
  }
1241
1244
  }
1242
1245
  }
1246
+ // 空调类型
1247
+ else if (mapping.device === 'custom_climate') {
1248
+ node.log(`[自定义空调] 处理状态: ${JSON.stringify(state)}, codes存在: ${!!codes}`);
1249
+ for (const [key, value] of Object.entries(state)) {
1250
+ let hexCode = null;
1251
+
1252
+ // 1. 开关控制 (处理 acSwitch, climateSwitch, switch)
1253
+ if (key === 'acSwitch' || key === 'climateSwitch' || key === 'switch') {
1254
+ const isOn = value === true || value === 1 || value === '1' || value === 'on' || value === 'ON';
1255
+ hexCode = isOn ? (codes.acSendOn || codes.sendOn) : (codes.acSendOff || codes.sendOff);
1256
+ node.log(`[自定义空调] 开关控制: ${key}=${value}, isOn=${isOn}, hexCode=${hexCode}`);
1257
+ }
1258
+ // 2. 风速控制
1259
+ else if (['acFanSpeed', 'fanSpeed', 'climateFanSpeed', 'fanMode', 'fan_speed', 'fanLevel', 'fan_level', 'speed'].includes(key)) {
1260
+ const val = parseInt(value);
1261
+ node.log(`[自定义空调] 风速控制: ${key}=${value}, val=${val}`);
1262
+ // 标准Mesh协议: 1=高, 2=中, 3=低, 4=自动
1263
+ if (val === 1) {
1264
+ hexCode = codes.fanSendHigh;
1265
+ node.log(`[自定义空调] 高风, hexCode=${hexCode}`);
1266
+ }
1267
+ else if (val === 2) {
1268
+ hexCode = codes.fanSendMid;
1269
+ node.log(`[自定义空调] 中风, hexCode=${hexCode}`);
1270
+ }
1271
+ else if (val === 3 || val === 0 || val === 4) {
1272
+ hexCode = codes.fanSendLow;
1273
+ node.log(`[自定义空调] 低风, hexCode=${hexCode}`);
1274
+ }
1275
+ }
1276
+ // 3. 模式控制
1277
+ else if (key === 'acMode' || key === 'climateMode' || key === 'mode') {
1278
+ if (value === 1) hexCode = codes.modeSendCool;
1279
+ else if (value === 2) hexCode = codes.modeSendHeat;
1280
+ else if (value === 4) hexCode = codes.modeSendDry;
1281
+ else if (value === 3) hexCode = codes.modeSendFan;
1282
+
1283
+ // 更新缓存中的模式
1284
+ if (!node.climateCache[mapping.meshMac]) node.climateCache[mapping.meshMac] = {};
1285
+ node.climateCache[mapping.meshMac].mode = value;
1286
+ }
1287
+ // 4. 温度控制
1288
+ else if (key === 'targetTemp' || key === 'acTargetTemp' || key === 'temperature') {
1289
+ const temp = Math.round(value);
1290
+ // 优先从当前变化中获取模式,否则从缓存中获取,默认制冷(1)
1291
+ const mode = state.acMode || state.climateMode || (node.climateCache[mapping.meshMac] ? node.climateCache[mapping.meshMac].mode : 1);
1292
+
1293
+ // 1. 优先尝试特定温度码,如 tempSendCool16
1294
+ const modeSuffix = (mode === 2) ? 'Heat' : 'Cool';
1295
+ const type = 'Send';
1296
+ const specificKey = 'temp' + type + modeSuffix + temp;
1297
+ hexCode = codes[specificKey];
1298
+
1299
+ // 2. 如果没有特定温度码,尝试模板
1300
+ if (!hexCode) {
1301
+ let template = (mode === 2) ? codes.tempSendHeat : codes.tempSendCool;
1302
+ if (!template) template = codes.tempSend; // 回退到通用模板
1303
+
1304
+ if (template && template.includes('{TEMP}')) {
1305
+ hexCode = template.replace('{TEMP}', temp.toString(16).toUpperCase().padStart(2, '0'));
1306
+ }
1307
+ }
1308
+ }
1309
+
1310
+ if (hexCode) {
1311
+ await node.sendCustomCode(hexCode);
1312
+ node.log(`[Mesh->自定义] 空调 ${key}=${value}, 发送: ${hexCode}`);
1313
+ }
1314
+ }
1315
+ }
1243
1316
  // 场景类型
1244
1317
  else if (mapping.device === 'custom_scene') {
1245
1318
  if (state.trigger && codes.trigger) {
@@ -1493,19 +1566,44 @@ module.exports = function(RED) {
1493
1566
  for (const [key, value] of Object.entries(state)) {
1494
1567
  try {
1495
1568
  // 开关类型
1496
- if (key === 'switch') {
1569
+ if (key === 'switch' || key.startsWith('switch_')) {
1570
+ const ch = key.startsWith('switch_') ? parseInt(key.replace('switch_', '')) : channel;
1497
1571
  const onOff = value ? 0x02 : 0x01;
1498
- const param = Buffer.from([channel - 1, onOff]);
1572
+ const param = Buffer.from([ch - 1, onOff]);
1573
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x02, param);
1574
+ node.log(`[自定义->Mesh] 开关${ch}: ${value ? '开' : '关'}`);
1575
+ }
1576
+ // 空调开关
1577
+ else if (key === 'acSwitch' || key === 'climateSwitch') {
1578
+ const param = Buffer.from([value ? 0x02 : 0x01]);
1499
1579
  await node.gateway.sendControl(meshDevice.networkAddress, 0x02, param);
1500
- node.log(`[自定义->Mesh] 开关${channel}: ${value ? '开' : '关'}`);
1580
+ node.log(`[自定义->Mesh] 空调开关: ${value ? '开' : '关'}`);
1581
+ }
1582
+ // 空调模式
1583
+ else if (key === 'acMode' || key === 'climateMode' || key === 'mode') {
1584
+ const param = Buffer.from([value]);
1585
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1D, param);
1586
+ node.log(`[自定义->Mesh] 空调模式: ${value}`);
1587
+ }
1588
+ // 空调风速
1589
+ else if (key === 'acFanSpeed' || key === 'fanMode' || key === 'fanSpeed') {
1590
+ const param = Buffer.from([value]);
1591
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1C, param);
1592
+ node.log(`[自定义->Mesh] 空调风速: ${value}`);
1593
+ }
1594
+ // 目标温度
1595
+ else if (key === 'acTargetTemp' || key === 'targetTemp') {
1596
+ const param = Buffer.from([Math.round(value)]);
1597
+ await node.gateway.sendControl(meshDevice.networkAddress, 0x1B, param);
1598
+ node.log(`[自定义->Mesh] 目标温度: ${value}°C`);
1501
1599
  }
1502
1600
  // 窗帘类型
1503
- else if (key === 'action' || key === 'position') {
1601
+ else if (key === 'action' || key === 'position' || key === 'curtainAction' || key === 'curtainPosition') {
1504
1602
  // 窗帘动作: 1=打开, 2=关闭, 3=停止
1505
1603
  let action = 0x03; // 停止
1506
- if (value === 'open') action = 0x01; // 打开
1507
- else if (value === 'close') action = 0x02; // 关闭
1508
- else if (value === 'stop') action = 0x03; // 停止
1604
+ if (value === 'open' || value === 1) action = 0x01; // 打开
1605
+ else if (value === 'close' || value === 2) action = 0x02; // 关闭
1606
+ else if (value === 'stop' || value === 3) action = 0x03; // 停止
1509
1607
 
1510
1608
  const param = Buffer.from([action]);
1511
1609
  // 0x05是窗帘动作控制属性
@@ -1715,7 +1813,7 @@ module.exports = function(RED) {
1715
1813
  node.parseRS485Frame = function(frame) {
1716
1814
  if (frame.length < 4) return;
1717
1815
 
1718
- const hexStr = frame.toString('hex').toUpperCase();
1816
+ const hexStr = frame.toString('hex').toUpperCase().replace(/\s/g, ''); // 确保完全去掉空格
1719
1817
  const hexFormatted = hexStr.match(/.{1,2}/g)?.join(' ') || '';
1720
1818
 
1721
1819
  node.log(`[RS485收到] ${hexFormatted} (${frame.length}字节)`);
@@ -1793,34 +1891,140 @@ module.exports = function(RED) {
1793
1891
  const codes = mapping.customCodes;
1794
1892
  let matchedAction = null;
1795
1893
 
1796
- node.debug(`[自定义码检测] 检查映射: device=${mapping.device}, meshMac=${mapping.meshMac}, codes=${JSON.stringify(codes)}`);
1894
+ node.log(`[自定义码检测] 检查映射: device=${mapping.device}, meshMac=${mapping.meshMac}`);
1797
1895
 
1798
- // 开关类型:匹配on/off
1896
+ // 开关类型:匹配recvOn/recvOff
1799
1897
  if (mapping.device === 'custom_switch') {
1800
- if (codes.on && hexStr.includes(codes.on.replace(/\s/g, '').toUpperCase())) {
1801
- matchedAction = { switch: true };
1802
- } else if (codes.off && hexStr.includes(codes.off.replace(/\s/g, '').toUpperCase())) {
1803
- matchedAction = { switch: false };
1898
+ const recvOn = (codes.recvOn || '').replace(/\s/g, '').toUpperCase();
1899
+ const recvOff = (codes.recvOff || '').replace(/\s/g, '').toUpperCase();
1900
+ node.log(`[自定义开关] recvOn=${recvOn}, recvOff=${recvOff}, hexStr=${hexStr}`);
1901
+ node.log(`[自定义开关] 包含recvOn? ${hexStr.includes(recvOn)}, 包含recvOff? ${hexStr.includes(recvOff)}`);
1902
+
1903
+ // 翻转模式:收开码=收关码
1904
+ if (recvOn && recvOff && recvOn === recvOff && hexStr.includes(recvOn)) {
1905
+ const device = node.gateway.deviceManager.getDeviceByMac(mapping.meshMac);
1906
+ const channel = mapping.meshChannel || 1;
1907
+ const stateKey = mapping.meshChannel > 1 ? `switch_${channel}` : 'switch';
1908
+ const currentState = device?.state?.[stateKey] || false;
1909
+ matchedAction = { switch: !currentState };
1910
+ node.log(`[自定义开关] 翻转: ${stateKey} ${currentState} -> ${!currentState}`);
1911
+ } else {
1912
+ if (recvOn && hexStr.includes(recvOn)) {
1913
+ matchedAction = { switch: true };
1914
+ node.log(`[自定义开关] 匹配到收开码`);
1915
+ } else if (recvOff && hexStr.includes(recvOff)) {
1916
+ matchedAction = { switch: false };
1917
+ node.log(`[自定义开关] 匹配到收关码`);
1918
+ }
1804
1919
  }
1805
1920
  }
1806
- // 窗帘类型:匹配open/close/stop(只设置action,避免重复发码)
1921
+ // 窗帘类型:匹配recvOpen/recvClose/recvStop
1807
1922
  else if (mapping.device === 'custom_curtain') {
1808
- const openCode = codes.open ? codes.open.replace(/\s/g, '').toUpperCase() : '';
1809
- const closeCode = codes.close ? codes.close.replace(/\s/g, '').toUpperCase() : '';
1810
- const stopCode = codes.stop ? codes.stop.replace(/\s/g, '').toUpperCase() : '';
1811
- node.debug(`[自定义窗帘匹配] 帧hex=${hexStr}, open=${openCode}, close=${closeCode}, stop=${stopCode}`);
1923
+ const recvOpen = (codes.recvOpen || '').replace(/\s/g, '').toUpperCase();
1924
+ const recvClose = (codes.recvClose || '').replace(/\s/g, '').toUpperCase();
1925
+ const recvStop = (codes.recvStop || '').replace(/\s/g, '').toUpperCase();
1812
1926
 
1813
- if (openCode && hexStr.includes(openCode)) {
1927
+ if (recvOpen && hexStr.includes(recvOpen)) {
1814
1928
  matchedAction = { action: 'open' };
1815
- node.log(`[自定义窗帘匹配] 匹配到打开码!`);
1816
- } else if (closeCode && hexStr.includes(closeCode)) {
1929
+ node.log(`[自定义窗帘] 收开码`);
1930
+ } else if (recvClose && hexStr.includes(recvClose)) {
1817
1931
  matchedAction = { action: 'close' };
1818
- node.log(`[自定义窗帘匹配] 匹配到关闭码!`);
1819
- } else if (stopCode && hexStr.includes(stopCode)) {
1932
+ node.log(`[自定义窗帘] 收关码`);
1933
+ } else if (recvStop && hexStr.includes(recvStop)) {
1820
1934
  matchedAction = { action: 'stop' };
1821
- node.log(`[自定义窗帘匹配] 匹配到停止码!`);
1822
- } else {
1823
- node.debug(`[自定义窗帘匹配] 未匹配到任何码`);
1935
+ node.log(`[自定义窗帘] 收停码`);
1936
+ }
1937
+ }
1938
+ // 空调类型:匹配收码
1939
+ else if (mapping.device === 'custom_climate') {
1940
+ if (codes.acRecvOn && hexStr.includes(codes.acRecvOn.replace(/\s/g, '').toUpperCase())) {
1941
+ matchedAction = { acSwitch: true };
1942
+ } else if (codes.acRecvOff && hexStr.includes(codes.acRecvOff.replace(/\s/g, '').toUpperCase())) {
1943
+ matchedAction = { acSwitch: false };
1944
+ }
1945
+ else if (codes.fanRecvHigh && hexStr.includes(codes.fanRecvHigh.replace(/\s/g, '').toUpperCase())) {
1946
+ matchedAction = { acFanSpeed: 1 };
1947
+ } else if (codes.fanRecvMid && hexStr.includes(codes.fanRecvMid.replace(/\s/g, '').toUpperCase())) {
1948
+ matchedAction = { acFanSpeed: 2 };
1949
+ } else if (codes.fanRecvLow && hexStr.includes(codes.fanRecvLow.replace(/\s/g, '').toUpperCase())) {
1950
+ matchedAction = { acFanSpeed: 3 };
1951
+ }
1952
+ else if (codes.modeRecvCool && hexStr.includes(codes.modeRecvCool.replace(/\s/g, '').toUpperCase())) {
1953
+ matchedAction = { acMode: 1 };
1954
+ } else if (codes.modeRecvHeat && hexStr.includes(codes.modeRecvHeat.replace(/\s/g, '').toUpperCase())) {
1955
+ matchedAction = { acMode: 2 };
1956
+ } else if (codes.modeRecvDry && hexStr.includes(codes.modeRecvDry.replace(/\s/g, '').toUpperCase())) {
1957
+ matchedAction = { acMode: 4 };
1958
+ } else if (codes.modeRecvFan && hexStr.includes(codes.modeRecvFan.replace(/\s/g, '').toUpperCase())) {
1959
+ matchedAction = { acMode: 3 };
1960
+ }
1961
+
1962
+ // 温度匹配 (独立于模式匹配,因为一帧可能同时包含模式和温度,或者只有温度)
1963
+ if (!matchedAction) {
1964
+ let foundTemp = false;
1965
+ const climateModes = [
1966
+ { suffix: 'Cool', val: 1 },
1967
+ { suffix: 'Heat', val: 2 }
1968
+ ];
1969
+
1970
+ // 1. 优先匹配 16-30度 独立码
1971
+ for (const m of climateModes) {
1972
+ for (let t = 16; t <= 30; t++) {
1973
+ const key = `tempRecv${m.suffix}${t}`;
1974
+ const code = (codes[key] || '').replace(/\s/g, '').toUpperCase();
1975
+ if (code && hexStr.includes(code)) {
1976
+ matchedAction = { acTargetTemp: t, acMode: m.val };
1977
+ foundTemp = true;
1978
+ break;
1979
+ }
1980
+ }
1981
+ if (foundTemp) break;
1982
+ }
1983
+
1984
+ // 2. 如果没匹配到独立码,匹配模板
1985
+ if (!foundTemp) {
1986
+ const tempTemplates = [
1987
+ { template: codes.tempRecvCool, mode: 1 },
1988
+ { template: codes.tempRecvHeat, mode: 2 },
1989
+ { template: codes.tempRecv, mode: 0 } // 通用
1990
+ ];
1991
+
1992
+ for (const item of tempTemplates) {
1993
+ if (item.template && item.template.includes('{TEMP}')) {
1994
+ const template = item.template.replace(/\s/g, '').toUpperCase();
1995
+ const parts = template.split('{TEMP}');
1996
+ if (parts.length === 2) {
1997
+ const prefix = parts[0];
1998
+ const suffix = parts[1];
1999
+ if (hexStr.includes(prefix)) {
2000
+ // 尝试从 hexStr 中提取温度
2001
+ const startIdx = hexStr.indexOf(prefix) + prefix.length;
2002
+ const tempHex = hexStr.substring(startIdx, startIdx + 2);
2003
+ const remaining = hexStr.substring(startIdx + 2);
2004
+
2005
+ if (tempHex.length === 2 && (!suffix || remaining.includes(suffix))) {
2006
+ const temp = parseInt(tempHex, 16);
2007
+ if (temp >= 16 && temp <= 30) {
2008
+ matchedAction = { acTargetTemp: temp };
2009
+ if (item.mode > 0) matchedAction.acMode = item.mode;
2010
+ foundTemp = true;
2011
+ break;
2012
+ }
2013
+ }
2014
+ }
2015
+ }
2016
+ }
2017
+ }
2018
+ }
2019
+ }
2020
+
2021
+ if (matchedAction) {
2022
+ // 更新缓存中的模式
2023
+ if (matchedAction.acMode !== undefined) {
2024
+ if (!node.climateCache[mapping.meshMac]) node.climateCache[mapping.meshMac] = {};
2025
+ node.climateCache[mapping.meshMac].mode = matchedAction.acMode;
2026
+ }
2027
+ node.log(`[自定义空调] 匹配: ${JSON.stringify(matchedAction)}`);
1824
2028
  }
1825
2029
  }
1826
2030
  // 场景类型:匹配trigger
@@ -160,7 +160,7 @@ module.exports = function(RED) {
160
160
  node.initTimer = setTimeout(() => {
161
161
  node.initializing = false;
162
162
  node.log('[KNX Bridge] 初始化完成,开始同步');
163
- }, 20000); // 20秒初始化延迟
163
+ }, 10000); // 10秒初始化延迟
164
164
 
165
165
  if (node.mappings.length === 0) {
166
166
  node.status({ fill: 'grey', shape: 'ring', text: '请添加实体映射' });
@@ -1342,11 +1342,27 @@ module.exports = function(RED) {
1342
1342
 
1343
1343
  } else if (topic.includes('/fan_mode/set')) {
1344
1344
  // 温控器/三合一空调风速 (0x1C协议: 1=高, 2=中, 3=低, 4=自动)
1345
+ node.log(`[MQTT] 收到风速控制: topic=${topic}, payload=${payload}`);
1345
1346
  const fans = { 'high': 1, 'medium': 2, 'low': 3, 'auto': 4 };
1346
1347
  const fan = fans[payload];
1348
+ node.log(`[MQTT] 风速映射: ${payload} -> ${fan}`);
1347
1349
  if (fan !== undefined) {
1348
1350
  commands.push({ attrType: 0x1C, param: Buffer.from([fan]) });
1349
- this.debug(`[MQTT解析] 空调风速命令: 0x1C=0x${fan.toString(16).toUpperCase()} (${payload})`);
1351
+ node.log(`[MQTT解析] 空调风速命令: 0x1C=0x${fan.toString(16).toUpperCase()} (${payload})`);
1352
+
1353
+ // 立即触发状态变化事件,通知RS485桥接节点
1354
+ if (device && node.gateway && node.gateway.deviceManager) {
1355
+ node.log(`[MQTT] 触发风速状态变化事件: fanMode=${fan}`);
1356
+ device.state.fanMode = fan;
1357
+ node.gateway.deviceManager.emit('stateChange', {
1358
+ device: device,
1359
+ state: { fanMode: fan },
1360
+ attrType: 0x1C,
1361
+ isUserControl: true
1362
+ });
1363
+ } else {
1364
+ node.warn(`[MQTT] 无法触发事件: device=${!!device}, gateway=${!!node.gateway}, deviceManager=${!!node.gateway?.deviceManager}`);
1365
+ }
1350
1366
  }
1351
1367
 
1352
1368
  } else if (topic.endsWith('/cover/set')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-mesh",
3
- "version": "1.6.9",
3
+ "version": "1.7.1",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "nodes/symi-gateway.js",
6
6
  "scripts": {