node-red-contrib-symi-modbus 2.10.17 → 2.10.20

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
@@ -487,35 +487,21 @@ msg.payload = 1; // 或 0
487
487
 
488
488
  ### 更新日志
489
489
 
490
- #### v2.10.17 (2026-02-07)
490
+ #### v2.10.20 (2026-06-30)
491
491
 
492
- - **Mesh 窗帘协议兼容性**:按照《蓝牙MESH网关(初级版)串口协议V1.0》修正 0x53 0x80 0x06 节点状态帧解析逻辑,明确区分事件类型 RESULT_EVENT_NODE_STATUS(0x06) `VD_MSG_TYPE_CURT_RUN_STATUS(0x05)` / `VD_MSG_TYPE_CURT_RUN_PER_POS(0x06)`,不再误把 `subOp` 当成窗帘 msg_type 使用,严格从数据区解析 `[msg_type][param]`。
493
- - **窗帘状态与端点记忆**:窗帘动作(0x05) 与位置(0x06) 在内部状态中分离存储,位置只由真正的 0x06 位置帧(含杜亚485位置反馈)更新;仅当位置为 0%/100% 且确认为当前运动方向的目标端点时才标记到达端点并更新 `lastAction/lastDirection`,避免 0x05 帧或“打开中收到0% / 关闭中收到100%”被误判为端点。
494
- - **开停关停 / 打开-关闭 行为**:
495
- - 引入 `isMoving` 统一表示“运动过程”,只要执行过开/关且未到端点(0%/100%),物理按键再次点击必发送暂停(3),不会直接翻转方向。
496
- - 停在 0%/100% 后会进行“端点收敛”:端点上报后不会再多走一次暂停,下一次按键一定是「关着→打开 / 开着→关闭」,到顶/到底后再次点击必然反向(关/开)。
497
- - 修正 cycle 模式下起点 0%/100% 刚起步时第二下按键误判为翻转的问题;到端点仅在 `isMoving=false && reachedEndpoint=true && endpointPosition=0/100` 时生效,并移除“>50% / <=50% 位置兜底”,中途暂停后的方向完全由 `lastDirection/lastAction` 决定,保证绝对的「开-停-关-停」节奏。
498
- - **Mesh 透传与日志**:合并重复 close 监听、Mesh 透传热路径日志改为 debug,减少生产环境刷屏;仍兼容 Mesh 动作反馈(0x05) 与位置反馈(0x06),但不再因为偶发“停止/运动中”状态抖动误判为已停止,避免运动过程误触发开/关。
499
- - **Clowire 场景按键**:Clowire 面板按键在从站开关 / 继电器输出 / Mesh 透传中统一按“场景按钮”处理,每次物理按下都会触发一次事件;运动中的窗帘再次按下必然发送暂停,不会误翻转方向。
492
+ - **[打包]** 修复 npm 发布包误含 `.codebase-memory/` 开发索引的问题,包体积约 158KB;规范 `.npmignore` / `.gitignore`,开发缓存目录不进入发布包。
493
+ - **[工程]** 提取 `lib/lru-map.js`、`lib/slave-switch-cache.js`,全局 LRU 缓存与 Mesh LED 定时器上限(5000);新增 `lib/http-admin-utils.js`,14 HTTP Admin 路由统一 read/write 权限校验。
494
+ - **[工程]** `modbus-master` 串口列表 API 复用 `lib/serial-utils`,消除重复实现。
495
+ - **[测试]** 新增 `mesh-protocol`、`lightweight-protocol` 协议帧解析单元测试(`npm test`)。
496
+ - **[修复]** 修正 `isMeshFrame` 请求帧长度判定,与 `parseAllFrames` 保持一致。
497
+ - **[Mesh 窗帘]** 按《蓝牙MESH网关(初级版)串口协议V1.0》修正 0x53 0x80 0x06 状态帧解析;窗帘动作/位置分离存储;开停关停与打开-关闭逻辑完善;Clowire 场景按键统一处理。
498
+ - **[Mesh 透传]** 合并重复 close 监听;热路径日志降为 debug;杜亚电机状态双向同步优化;实体持久化增强。
500
499
 
501
- #### v2.10.16 (2026-02-06)
500
+ #### 更早版本 (v2.10.14 ~ v2.10.17)
502
501
 
503
- - **按键记忆**:同一帧内若同时上报位置与动作(如 position=100 + action=打开中),以位置为准到端点即按「已停止」记忆,避免被动作覆盖导致下次按键误判。
504
- - **开停关停 / 打开关闭**:状态记忆更稳,到顶/到底后再次按键必为关/开;开停关停模式下运动中点击会正确发送暂停(3)。
505
- - **场景按键**:场景模式下不再因 value=false 忽略一次按下,每次物理按键都会触发控制,解决「多按一次」才生效的问题。
506
-
507
- #### v2.10.15 (2026-02-05)
508
-
509
- - **Mesh 窗帘控制**:修复重启后实体消失、到头不记忆位置、再次点击误发暂停等问题;新增「打开/关闭」单键循环逻辑;「开-停-关-停」循环逻辑完整可用。
510
- - **配置界面**:控制逻辑选项正确保存与显示,选择「打开/关闭」后不再被覆盖为「循环」。
511
- - **Mesh 透传与杜亚**:与杜亚电机状态双向同步优化;窗帘到端点(0%/100%)状态正确同步到按键,点击时能正确执行开/关。
512
- - **稳定性**:实体持久化与存储校验增强,兼容更多固件版本的窗帘协议。
513
-
514
- #### v2.10.14 (2026-02-03)
515
-
516
- - 多种 Modbus 协议支持(Telnet ASCII、RTU over TCP、TCP、串口);智能轮询暂停,面板事件响应更及时。
517
- - Home Assistant MQTT Discovery 与实体唯一 ID;串口热插拔与 TCP 故障自动恢复。
518
- - 开关/场景按钮双模式,LED 反馈同步。
502
+ - 多种 Modbus 协议(Telnet ASCII、RTU over TCP、TCP、串口)与智能轮询暂停。
503
+ - Home Assistant MQTT Discovery、HomeKit 网桥、控制看板、Mesh 透传节点、继电器输出。
504
+ - 按键记忆与端点收敛、场景按键每次按下均触发、LED 反馈同步、串口热插拔与 TCP 自动恢复。
519
505
 
520
506
  ## 许可证
521
507
  MIT License
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Node-RED HTTP Admin 路由注册(统一权限校验)
5
+ */
6
+ function needsPermission(RED, permission) {
7
+ if (RED.auth && typeof RED.auth.needsPermission === "function") {
8
+ return RED.auth.needsPermission(permission);
9
+ }
10
+ return function passThrough(req, res, next) {
11
+ if (typeof next === "function") {
12
+ next();
13
+ }
14
+ };
15
+ }
16
+
17
+ function registerGet(RED, path, permission, handler) {
18
+ RED.httpAdmin.get(path, needsPermission(RED, permission), handler);
19
+ }
20
+
21
+ function registerPost(RED, path, permission, handler) {
22
+ RED.httpAdmin.post(path, needsPermission(RED, permission), handler);
23
+ }
24
+
25
+ function registerDelete(RED, path, permission, handler) {
26
+ RED.httpAdmin.delete(path, needsPermission(RED, permission), handler);
27
+ }
28
+
29
+ module.exports = {
30
+ needsPermission,
31
+ registerGet,
32
+ registerPost,
33
+ registerDelete
34
+ };
package/lib/lru-map.js ADDED
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * 带大小限制的 LRU Map,用于全局缓存防内存泄漏
5
+ */
6
+ class LRUMap extends Map {
7
+ constructor(maxSize, onEvict) {
8
+ super();
9
+ this.maxSize = maxSize;
10
+ this.onEvict = typeof onEvict === "function" ? onEvict : null;
11
+ }
12
+
13
+ set(key, value) {
14
+ if (this.has(key)) {
15
+ this.delete(key);
16
+ } else if (this.size >= this.maxSize) {
17
+ const firstKey = this.keys().next().value;
18
+ this.delete(firstKey);
19
+ if (this.onEvict) {
20
+ this.onEvict(firstKey);
21
+ }
22
+ }
23
+ return super.set(key, value);
24
+ }
25
+
26
+ get(key) {
27
+ const value = super.get(key);
28
+ if (value !== undefined) {
29
+ this.delete(key);
30
+ super.set(key, value);
31
+ }
32
+ return value;
33
+ }
34
+ }
35
+
36
+ module.exports = { LRUMap };
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+
3
+ const { LRUMap } = require("./lru-map");
4
+
5
+ const GLOBAL_CACHE_CONFIG = {
6
+ maxSize: 10000,
7
+ cleanupInterval: 600000,
8
+ expireTime: 3600000,
9
+ meshLedTimerMaxSize: 5000
10
+ };
11
+
12
+ /**
13
+ * 从站开关节点全局缓存(LRU + 定期清理)
14
+ */
15
+ function createSlaveSwitchCaches(RED) {
16
+ const onEvict = (key) => {
17
+ if (RED && RED.log && RED.log.debug) {
18
+ RED.log.debug(`LRU缓存淘汰: 删除键 ${key}`);
19
+ }
20
+ };
21
+
22
+ const globalDebounceCache = new LRUMap(GLOBAL_CACHE_CONFIG.maxSize, onEvict);
23
+ const meshDeviceStates = new LRUMap(GLOBAL_CACHE_CONFIG.maxSize, onEvict);
24
+ const meshWirelessButtons = new LRUMap(GLOBAL_CACHE_CONFIG.maxSize, onEvict);
25
+ const globalButtonRelayStates = new LRUMap(GLOBAL_CACHE_CONFIG.maxSize, onEvict);
26
+ const meshLedDebounceTimers = new Map();
27
+
28
+ function getScopedKey(node, key) {
29
+ const configId = (node.serialPortConfig && node.serialPortConfig.id) || node.id || "global";
30
+ return `${configId}:${key}`;
31
+ }
32
+
33
+ let cleanupTimer = null;
34
+
35
+ function startGlobalDebounceCacheCleanup() {
36
+ if (cleanupTimer) {
37
+ return;
38
+ }
39
+ cleanupTimer = setInterval(() => {
40
+ const now = Date.now();
41
+ const expireThreshold = 60 * 1000;
42
+ let cleanedCount = 0;
43
+
44
+ for (const [key, timestamp] of globalDebounceCache.entries()) {
45
+ if (now - timestamp > expireThreshold) {
46
+ globalDebounceCache.delete(key);
47
+ cleanedCount++;
48
+ }
49
+ }
50
+
51
+ // Mesh LED 防抖定时器:清理已失效条目并限制最大数量
52
+ for (const [key, timerObj] of meshLedDebounceTimers.entries()) {
53
+ if (!timerObj || !timerObj.timer) {
54
+ meshLedDebounceTimers.delete(key);
55
+ cleanedCount++;
56
+ }
57
+ }
58
+ while (meshLedDebounceTimers.size > GLOBAL_CACHE_CONFIG.meshLedTimerMaxSize) {
59
+ const firstKey = meshLedDebounceTimers.keys().next().value;
60
+ const timerObj = meshLedDebounceTimers.get(firstKey);
61
+ if (timerObj && timerObj.timer) {
62
+ clearTimeout(timerObj.timer);
63
+ }
64
+ meshLedDebounceTimers.delete(firstKey);
65
+ cleanedCount++;
66
+ }
67
+
68
+ if (cleanedCount > 0 && RED && RED.log && RED.log.debug) {
69
+ RED.log.debug(
70
+ `全局缓存清理: 删除${cleanedCount}条记录,防抖${globalDebounceCache.size}条,LED定时器${meshLedDebounceTimers.size}条`
71
+ );
72
+ }
73
+ }, 10 * 60 * 1000);
74
+ }
75
+
76
+ return {
77
+ GLOBAL_CACHE_CONFIG,
78
+ globalDebounceCache,
79
+ meshDeviceStates,
80
+ meshWirelessButtons,
81
+ meshLedDebounceTimers,
82
+ globalButtonRelayStates,
83
+ getScopedKey,
84
+ startGlobalDebounceCacheCleanup
85
+ };
86
+ }
87
+
88
+ module.exports = { createSlaveSwitchCaches, GLOBAL_CACHE_CONFIG };
@@ -1,5 +1,6 @@
1
1
  module.exports = function(RED) {
2
2
  "use strict";
3
+ const httpAdmin = require("../lib/http-admin-utils");
3
4
 
4
5
  function CustomProtocolNode(config) {
5
6
  RED.nodes.createNode(this, config);
@@ -173,7 +174,7 @@ module.exports = function(RED) {
173
174
  RED.nodes.registerType("custom-protocol", CustomProtocolNode);
174
175
 
175
176
  // HTTP API:测试发送指令
176
- RED.httpAdmin.post("/custom-protocol/test", function(req, res) {
177
+ httpAdmin.registerPost(RED, "/custom-protocol/test", "custom-protocol.write", function(req, res) {
177
178
  var serialConfigId = req.body.serialConfig;
178
179
  var hexString = req.body.hexString;
179
180
  var cmdName = req.body.cmdName;
@@ -1,6 +1,7 @@
1
1
  module.exports = function(RED) {
2
2
  "use strict";
3
3
  const { ensureRedEventsMaxListeners } = require("../lib/red-events-utils");
4
+ const httpAdmin = require("../lib/http-admin-utils");
4
5
  ensureRedEventsMaxListeners(RED);
5
6
  const hap = require("hap-nodejs");
6
7
  const storage = require("node-persist");
@@ -335,7 +336,7 @@ module.exports = function(RED) {
335
336
  RED.nodes.registerType("homekit-bridge", HomekitBridgeNode);
336
337
 
337
338
  // HTTP API:获取主站节点配置
338
- RED.httpAdmin.get("/homekit-bridge/master-config/:id", function(req, res) {
339
+ httpAdmin.registerGet(RED, "/homekit-bridge/master-config/:id", "homekit-bridge.read", function(req, res) {
339
340
  var masterNodeId = req.params.id;
340
341
  var masterNode = RED.nodes.getNode(masterNodeId);
341
342
 
@@ -512,18 +512,31 @@ module.exports = function(_RED) {
512
512
  * @returns {boolean} 是否有效
513
513
  */
514
514
  function isMeshFrame(buffer) {
515
- if (!buffer || buffer.length < 5) {return false;}
515
+ if (!buffer || buffer.length < 4) {return false;}
516
516
  if (buffer[0] !== PROTOCOL.HEADER) {return false;}
517
-
518
- const dataLen = buffer[3];
519
- const totalLen = 4 + dataLen + 1;
520
-
517
+
518
+ const opCode = buffer[1];
519
+ let dataLenIndex;
520
+ let overhead;
521
+
522
+ if (opCode >= 0x80) {
523
+ dataLenIndex = 3;
524
+ overhead = 5;
525
+ } else {
526
+ dataLenIndex = 2;
527
+ overhead = 4;
528
+ }
529
+
530
+ if (dataLenIndex >= buffer.length) {return false;}
531
+
532
+ const dataLen = buffer[dataLenIndex];
533
+ const totalLen = overhead + dataLen;
534
+
521
535
  if (buffer.length !== totalLen) {return false;}
522
-
523
- // 校验和验证
536
+
524
537
  const receivedChecksum = buffer[buffer.length - 1];
525
538
  const calculatedChecksum = calculateChecksum(buffer.slice(0, buffer.length - 1));
526
-
539
+
527
540
  return receivedChecksum === calculatedChecksum;
528
541
  }
529
542
 
@@ -1,5 +1,6 @@
1
1
  module.exports = function(RED) {
2
2
  "use strict";
3
+ const httpAdmin = require("../lib/http-admin-utils");
3
4
 
4
5
  // 全局状态缓存(所有dashboard节点共享)
5
6
  var globalStateCache = {};
@@ -87,7 +88,7 @@ module.exports = function(RED) {
87
88
  RED.nodes.registerType("modbus-dashboard", ModbusDashboardNode);
88
89
 
89
90
  // HTTP API:获取主站节点配置
90
- RED.httpAdmin.get("/modbus-dashboard/master-config/:id", function(req, res) {
91
+ httpAdmin.registerGet(RED, "/modbus-dashboard/master-config/:id", "modbus-dashboard.read", function(req, res) {
91
92
  var masterNodeId = req.params.id;
92
93
  var masterNode = RED.nodes.getNode(masterNodeId);
93
94
 
@@ -105,7 +106,7 @@ module.exports = function(RED) {
105
106
  });
106
107
 
107
108
  // HTTP API:获取状态
108
- RED.httpAdmin.get("/modbus-dashboard/state/:masterNodeId", function(req, res) {
109
+ httpAdmin.registerGet(RED, "/modbus-dashboard/state/:masterNodeId", "modbus-dashboard.read", function(req, res) {
109
110
  var masterNodeId = req.params.masterNodeId;
110
111
  var states = {};
111
112
 
@@ -123,7 +124,7 @@ module.exports = function(RED) {
123
124
  });
124
125
 
125
126
  // HTTP API:刷新状态(从主站重新获取真实状态)
126
- RED.httpAdmin.post("/modbus-dashboard/refresh/:masterNodeId", function(req, res) {
127
+ httpAdmin.registerPost(RED, "/modbus-dashboard/refresh/:masterNodeId", "modbus-dashboard.write", function(req, res) {
127
128
  var masterNodeId = req.params.masterNodeId;
128
129
  var masterNode = RED.nodes.getNode(masterNodeId);
129
130
 
@@ -170,7 +171,7 @@ module.exports = function(RED) {
170
171
  });
171
172
 
172
173
  // HTTP API:发送控制命令
173
- RED.httpAdmin.post("/modbus-dashboard/control", function(req, res) {
174
+ httpAdmin.registerPost(RED, "/modbus-dashboard/control", "modbus-dashboard.write", function(req, res) {
174
175
  var slave = parseInt(req.body.slave);
175
176
  var coil = parseInt(req.body.coil);
176
177
  var value = Boolean(req.body.value);
@@ -4,85 +4,19 @@ module.exports = function(RED) {
4
4
  const mqtt = require("mqtt");
5
5
  const protocol = require("./lightweight-protocol");
6
6
  const mqttHelper = require("./mqtt-helper");
7
+ const serialUtils = require("../lib/serial-utils");
8
+ const httpAdmin = require("../lib/http-admin-utils");
7
9
 
8
10
  // 全局主站实例注册表(用于多主站独立运行,避免互相干扰)
9
11
  // 每个主站节点有独立的Modbus客户端实例,互不影响
10
12
  const masterInstances = new Map();
11
13
 
12
- // 串口列表API - 支持Windows、Linux、macOS所有串口设备
13
- RED.httpAdmin.get("/modbus-master/serialports", async function(req, res) {
14
+ // 串口列表API - 使用公共串口工具模块
15
+ httpAdmin.registerGet(RED, "/modbus-master/serialports", "modbus-master.read", async function(req, res) {
14
16
  try {
15
- // 尝试从多个可能的位置获取serialport模块
16
- let SerialPort;
17
- try {
18
- // 优先尝试使用node_modules中的serialport
19
- SerialPort = require("serialport");
20
- } catch (e) {
21
- try {
22
- // 如果失败,尝试从modbus-serial的依赖中获取
23
- const ModbusRTU = require("modbus-serial");
24
- SerialPort = ModbusRTU.SerialPort || require("serialport");
25
- } catch (e2) {
26
- // 两种方式都失败,返回特殊错误标记
27
- RED.log.warn("串口模块未找到,可能需要额外安装 serialport 模块");
28
- return res.json([{
29
- comName: "未检测到串口",
30
- manufacturer: "Docker环境需要映射设备:--device=/dev/ttyUSB0 或 --privileged",
31
- isError: true
32
- }]);
33
- }
34
- }
35
-
36
- // serialport v10+ (使用SerialPort.SerialPort.list)
37
- if (SerialPort && SerialPort.SerialPort && SerialPort.SerialPort.list) {
38
- const ports = await SerialPort.SerialPort.list();
39
- if (ports.length === 0) {
40
- // 没有发现串口,返回提示信息
41
- return res.json([{
42
- comName: "未检测到串口",
43
- manufacturer: "Docker环境需要映射设备:--device=/dev/ttyUSB0 或 --privileged",
44
- isError: true
45
- }]);
46
- }
47
- const portList = ports.map(port => ({
48
- comName: port.path || port.comName,
49
- manufacturer: port.manufacturer || "未知设备",
50
- vendorId: port.vendorId || "",
51
- productId: port.productId || "",
52
- isError: false
53
- }));
54
- return res.json(portList);
55
- }
56
-
57
- // serialport v9 (使用SerialPort.list)
58
- if (SerialPort && SerialPort.list) {
59
- const ports = await SerialPort.list();
60
- if (ports.length === 0) {
61
- // 没有发现串口,返回提示信息
62
- return res.json([{
63
- comName: "未检测到串口",
64
- manufacturer: "Docker环境需要映射设备:--device=/dev/ttyUSB0 或 --privileged",
65
- isError: true
66
- }]);
67
- }
68
- const portList = ports.map(port => ({
69
- comName: port.path || port.comName,
70
- manufacturer: port.manufacturer || "未知设备",
71
- vendorId: port.vendorId || "",
72
- productId: port.productId || "",
73
- isError: false
74
- }));
75
- return res.json(portList);
76
- }
77
-
78
- // 如果以上方法都不可用,返回提示信息
79
- res.json([{
80
- comName: "串口功能不可用",
81
- manufacturer: "请手动输入串口路径,如:/dev/ttyUSB0",
82
- isError: true
83
- }]);
17
+ const ports = await serialUtils.getSerialPortListWithTips(RED);
18
+ res.json(ports);
84
19
  } catch (err) {
85
- // 发生错误时记录日志并返回错误信息
86
20
  RED.log.warn(`串口列表获取失败: ${err.message}`);
87
21
  res.json([{
88
22
  comName: "串口搜索失败",
@@ -9,95 +9,20 @@ module.exports = function(RED) {
9
9
  const path = require("path");
10
10
  const os = require("os");
11
11
  const serialUtils = require("../lib/serial-utils");
12
+ const httpAdmin = require("../lib/http-admin-utils");
13
+ const { createSlaveSwitchCaches } = require("../lib/slave-switch-cache");
14
+
15
+ const {
16
+ globalDebounceCache,
17
+ meshDeviceStates,
18
+ meshWirelessButtons,
19
+ meshLedDebounceTimers,
20
+ globalButtonRelayStates,
21
+ getScopedKey,
22
+ startGlobalDebounceCacheCleanup
23
+ } = createSlaveSwitchCaches(RED);
12
24
 
13
- // 全局缓存配置
14
- const GLOBAL_CACHE_CONFIG = {
15
- maxSize: 10000, // 最大缓存条目数
16
- cleanupInterval: 600000, // 清理间隔:10分钟
17
- expireTime: 3600000 // 过期时间:1小时
18
- };
19
-
20
- // LRU Map 实现(带大小限制)
21
- class LRUMap extends Map {
22
- constructor(maxSize) {
23
- super();
24
- this.maxSize = maxSize;
25
- }
26
-
27
- set(key, value) {
28
- // 如果 key 已存在,先删除再添加(保持最新访问顺序)
29
- if (this.has(key)) {
30
- this.delete(key);
31
- }
32
- // 如果超过最大大小,删除最旧的条目
33
- else if (this.size >= this.maxSize) {
34
- const firstKey = this.keys().next().value;
35
- this.delete(firstKey);
36
- RED.log.debug(`LRU缓存淘汰: 删除键 ${firstKey}`);
37
- }
38
- return super.set(key, value);
39
- }
40
-
41
- get(key) {
42
- const value = super.get(key);
43
- if (value !== undefined) {
44
- // 移动到最新位置(LRU策略)
45
- this.delete(key);
46
- super.set(key, value);
47
- }
48
- return value;
49
- }
50
- }
51
-
52
- // 全局防抖缓存:防止多个节点重复处理同一个按键事件
53
- // key: "serialPortConfigId:switchId-buttonNumber", value: timestamp
54
- const globalDebounceCache = new LRUMap(GLOBAL_CACHE_CONFIG.maxSize);
55
-
56
- // 全局Mesh设备状态缓存(按短地址索引)
57
- // key: "serialPortConfigId:meshShortAddress", value: [state1, state2, ...]
58
- const meshDeviceStates = new LRUMap(GLOBAL_CACHE_CONFIG.maxSize);
59
-
60
- // 全局Mesh无线模式按键标记(按短地址索引)
61
- // key: "serialPortConfigId:meshShortAddress", value: Set([buttonNumber1, buttonNumber2, ...])
62
- const meshWirelessButtons = new LRUMap(GLOBAL_CACHE_CONFIG.maxSize);
63
-
64
- // 全局Mesh设备LED反馈防抖定时器(按短地址索引)
65
- // key: "serialPortConfigId:meshShortAddress", value: {timer, nodeId, serialPortConfig}
66
- const meshLedDebounceTimers = new Map(); // 定时器需要精确控制,不使用LRU
67
-
68
- // 全局按钮关联继电器状态表(用于多对一控制时的LED状态聚合)
69
- // key: "serialPortConfigId:switchId:buttonNumber", value: Map<nodeId, boolean>
70
- const globalButtonRelayStates = new LRUMap(GLOBAL_CACHE_CONFIG.maxSize);
71
-
72
- // 辅助函数:获取带作用域的键名
73
- // 确保configId始终有效,避免返回"default"前缀导致不同配置下的节点共用防抖键
74
- function getScopedKey(node, key) {
75
- // 优先使用serialPortConfig.id,如果不存在则使用node.id作为fallback,最后使用 "global"
76
- const configId = (node.serialPortConfig && node.serialPortConfig.id) || node.id || "global";
77
- return `${configId}:${key}`;
78
- }
79
-
80
- // 全局防抖缓存定期清理(每10分钟清理超过1分钟未使用的条目,防止内存泄漏)
81
- let globalDebounceCacheCleanupTimer = null;
82
- function startGlobalDebounceCacheCleanup() {
83
- if (globalDebounceCacheCleanupTimer) {return;}
84
- globalDebounceCacheCleanupTimer = setInterval(() => {
85
- const now = Date.now();
86
- const expireThreshold = 60 * 1000; // 1分钟未使用的条目将被清理
87
- let cleanedCount = 0;
88
- for (const [key, timestamp] of globalDebounceCache.entries()) {
89
- if (now - timestamp > expireThreshold) {
90
- globalDebounceCache.delete(key);
91
- cleanedCount++;
92
- }
93
- }
94
- if (cleanedCount > 0) {
95
- RED.log.debug(`全局防抖缓存清理: 删除${cleanedCount}条过期记录,剩余${globalDebounceCache.size}条`);
96
- }
97
- }, 10 * 60 * 1000); // 每10分钟执行一次
98
- }
99
25
  startGlobalDebounceCacheCleanup();
100
-
101
26
  // 初始化Mesh设备持久化存储
102
27
  const meshPersistDir = path.join(RED.settings.userDir || os.homedir() + "/.node-red", "mesh-devices-persist");
103
28
 
@@ -127,7 +52,7 @@ module.exports = function(RED) {
127
52
  }
128
53
 
129
54
  // Mesh设备发现API(使用共享连接)
130
- RED.httpAdmin.post("/symi-mesh/discover", async function(req, res) {
55
+ httpAdmin.registerPost(RED, "/symi-mesh/discover", "modbus-slave-switch.write", async function(req, res) {
131
56
  try {
132
57
  const serialPortConfigId = req.body.serialPortConfig;
133
58
  RED.log.info(`[Mesh设备发现] 收到请求,连接配置ID: ${serialPortConfigId}`);
@@ -320,7 +245,7 @@ module.exports = function(RED) {
320
245
  });
321
246
 
322
247
  // 获取已保存的Mesh设备列表API
323
- RED.httpAdmin.get("/symi-mesh/devices", async function(req, res) {
248
+ httpAdmin.registerGet(RED, "/symi-mesh/devices", "modbus-slave-switch.read", async function(req, res) {
324
249
  try {
325
250
  await initMeshStorage();
326
251
  const deviceMap = await storage.getItem("meshDeviceMap") || {};
@@ -343,7 +268,7 @@ module.exports = function(RED) {
343
268
  });
344
269
 
345
270
  // 串口列表API - 使用公共串口工具模块
346
- RED.httpAdmin.get("/modbus-slave-switch/serialports", async function(req, res) {
271
+ httpAdmin.registerGet(RED, "/modbus-slave-switch/serialports", "modbus-slave-switch.read", async function(req, res) {
347
272
  try {
348
273
  const ports = await serialUtils.getSerialPortList(RED);
349
274
  // 转换为旧版格式以保持兼容性
@@ -10,6 +10,7 @@ module.exports = function(RED) {
10
10
  const storage = require("node-persist");
11
11
  const meshProtocol = require("./mesh-protocol")(RED);
12
12
  const serialUtils = require("../lib/serial-utils");
13
+ const httpAdmin = require("../lib/http-admin-utils");
13
14
 
14
15
  const { SerialPort } = require("serialport");
15
16
  const net = require("net");
@@ -1134,7 +1135,7 @@ module.exports = function(RED) {
1134
1135
  RED.nodes.registerType("serial-port-config", SerialPortConfigNode);
1135
1136
 
1136
1137
  // 提供串口搜索API - 使用公共串口工具模块
1137
- RED.httpAdmin.get("/serial-ports", async (_req, res) => {
1138
+ httpAdmin.registerGet(RED, "/serial-ports", "serial-port-config.read", async (_req, res) => {
1138
1139
  try {
1139
1140
  const ports = await serialUtils.getSerialPortList(RED);
1140
1141
  res.json(ports);
@@ -1144,7 +1145,7 @@ module.exports = function(RED) {
1144
1145
  });
1145
1146
 
1146
1147
  // 提供Mesh实体列表API (用于透明节点选择)
1147
- RED.httpAdmin.get("/symi-mesh/entities", (req, res) => {
1148
+ httpAdmin.registerGet(RED, "/symi-mesh/entities", "serial-port-config.read", (req, res) => {
1148
1149
  try {
1149
1150
  const configId = req.query.configId;
1150
1151
  if (!configId) {
@@ -1199,7 +1200,7 @@ module.exports = function(RED) {
1199
1200
  });
1200
1201
 
1201
1202
  // 删除Mesh实体API
1202
- RED.httpAdmin.delete("/symi-mesh/entity/:configId/:shortAddr", RED.auth.needsPermission("serial-port-config.write"), async (req, res) => {
1203
+ httpAdmin.registerDelete(RED, "/symi-mesh/entity/:configId/:shortAddr", "serial-port-config.write", async (req, res) => {
1203
1204
  try {
1204
1205
  const configId = req.params.configId;
1205
1206
  const shortAddr = req.params.shortAddr;
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-modbus",
3
- "version": "2.10.17",
3
+ "version": "2.10.20",
4
4
  "description": "Node-RED Modbus节点,支持TCP/串口通信、多主站独立运行、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步(支持Symi/Clowire品牌),工控机长期稳定运行",
5
5
  "main": "nodes/modbus-master.js",
6
6
  "scripts": {
7
7
  "lint": "eslint nodes/*.js lib/*.js --ext .js",
8
8
  "lint:fix": "eslint nodes/*.js lib/*.js --ext .js --fix",
9
- "test": "echo \"Error: no test specified\" && exit 0"
9
+ "test": "node --test test/*.test.js"
10
10
  },
11
11
  "keywords": [
12
12
  "node-red",