node-red-contrib-symi-modbus 2.10.17 → 2.10.19
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 +12 -0
- package/lib/http-admin-utils.js +34 -0
- package/lib/lru-map.js +36 -0
- package/lib/slave-switch-cache.js +88 -0
- package/nodes/custom-protocol.js +2 -1
- package/nodes/homekit-bridge.js +2 -1
- package/nodes/modbus-dashboard.js +5 -4
- package/nodes/modbus-master.js +6 -72
- package/nodes/modbus-slave-switch.js +15 -90
- package/nodes/serial-port-config.js +4 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -487,6 +487,18 @@ msg.payload = 1; // 或 0
|
|
|
487
487
|
|
|
488
488
|
### 更新日志
|
|
489
489
|
|
|
490
|
+
#### v2.10.19 (2026-06-30)
|
|
491
|
+
|
|
492
|
+
- **[工程]** 提取 `lib/lru-map.js`、`lib/slave-switch-cache.js`,精简 `modbus-slave-switch` 全局缓存逻辑并限制 Mesh LED 定时器上限(5000)。
|
|
493
|
+
- **[工程]** 新增 `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
|
+
|
|
497
|
+
#### v2.10.18 (2026-06-30)
|
|
498
|
+
|
|
499
|
+
- **[打包]** 修复 npm 发布包误含 `.codebase-memory/` 开发索引文件的问题,包体积从约 493KB 降至约 158KB,避免将本地知识图谱索引发布到 npm。
|
|
500
|
+
- **[工程]** 在 `.npmignore` 与 `.gitignore` 中排除 `.codebase-memory/`,确保开发工具产物不会进入发布包或版本库。
|
|
501
|
+
|
|
490
502
|
#### v2.10.17 (2026-02-07)
|
|
491
503
|
|
|
492
504
|
- **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]`。
|
|
@@ -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 };
|
package/nodes/custom-protocol.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/nodes/homekit-bridge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/nodes/modbus-master.js
CHANGED
|
@@ -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 -
|
|
13
|
-
|
|
14
|
+
// 串口列表API - 使用公共串口工具模块
|
|
15
|
+
httpAdmin.registerGet(RED, "/modbus-master/serialports", "modbus-master.read", async function(req, res) {
|
|
14
16
|
try {
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "2.10.19",
|
|
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": "
|
|
9
|
+
"test": "node --test test/*.test.js"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
12
12
|
"node-red",
|