node-red-contrib-symi-modbus 2.10.16 → 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 +37 -2
- 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/mesh-protocol.js +0 -31
- package/nodes/mesh-transparent-node.js +305 -219
- package/nodes/modbus-dashboard.js +5 -4
- package/nodes/modbus-master.js +65 -79
- package/nodes/modbus-slave-switch.js +22 -91
- package/nodes/relay-output.html +2 -3
- package/nodes/relay-output.js +5 -2
- package/nodes/serial-port-config.js +4 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -117,9 +117,12 @@ node-red-restart
|
|
|
117
117
|
1. 拖拽 **从站开关** 节点到流程画布
|
|
118
118
|
2. 选择刚创建的RS-485连接配置
|
|
119
119
|
3. 配置开关面板信息:
|
|
120
|
-
- 面板品牌: `Symi`
|
|
120
|
+
- 面板品牌: `Symi` / `Clowire`(克伦威尔)
|
|
121
121
|
- 开关ID: 物理面板地址 (0-255)
|
|
122
122
|
- 按钮编号: 按键编号 (1-8)
|
|
123
|
+
- 按钮类型:
|
|
124
|
+
- `开关按钮`:Symi 面板下,按下=开,释放=关(有独立开/关码),适合直接映射单个继电器
|
|
125
|
+
- `场景按钮`:**Clowire 推荐使用**,以及 Symi 场景键;每次按下都触发一次 Toggle,由指示灯和继电器状态共同决定“当前是开还是关”
|
|
123
126
|
4. 配置映射到的继电器:
|
|
124
127
|
- 目标从站地址: `10`
|
|
125
128
|
- 目标线圈编号: `0`
|
|
@@ -142,7 +145,16 @@ node-red-restart
|
|
|
142
145
|
1. 拖拽 **Mesh透传** 节点。
|
|
143
146
|
2. 选择对应的 **RS-485配置节点**(连接到蓝牙 Mesh 网关)。
|
|
144
147
|
3. 节点会自动发现网关下的子设备,选择目标窗帘或杜亚电机。
|
|
145
|
-
4.
|
|
148
|
+
4. 配置 **控制逻辑**(仅窗帘类型显示):
|
|
149
|
+
- **循环 (开-停-关-停)**:单键依次 打开→暂停→关闭→暂停。
|
|
150
|
+
- **打开/关闭**:单键在 开/关 之间切换,到顶/到底后再次按键必为 关/开。
|
|
151
|
+
5. 可选绑定 **按键触发**:
|
|
152
|
+
- 使用 `从站开关` 或 `继电器输出` 节点,面板品牌选择 Symi / Clowire,按钮类型选择 **场景按钮**,并与 Mesh 窗帘节点的 `switchId` / `buttonNumber` 保持一致
|
|
153
|
+
- Clowire 按键在内部统一按“场景模式”处理:每次物理按键都会触发一次事件,不会因为 `value=false` 被忽略
|
|
154
|
+
6. **状态与按键记忆**:窗帘上报的位置(0%/100%)会更新节点内部状态;下次同一按键按下时,会根据“到顶/到底”正确执行 关/开 或 暂停,无需额外配置。
|
|
155
|
+
- 运动过程中(收到中间百分比,未到 0%/100%),再次按键一律发送 **暂停**,不会发生错误翻转
|
|
156
|
+
- 只有在电机真正上报到端点(Mesh 0x06 / 杜亚 0x04 的 0% / 100%)时,才会记为“开着/关着”,下一次按键才直接翻转为 关/开
|
|
157
|
+
7. 支持杜亚 485 电机:勾选“启用解析”、填写电机地址(高位-低位),可同时控制 Mesh 窗帘与杜亚电机并双向同步位置。
|
|
146
158
|
|
|
147
159
|
## 核心特性说明
|
|
148
160
|
|
|
@@ -475,6 +487,29 @@ msg.payload = 1; // 或 0
|
|
|
475
487
|
|
|
476
488
|
### 更新日志
|
|
477
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
|
+
|
|
502
|
+
#### v2.10.17 (2026-02-07)
|
|
503
|
+
|
|
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]`。
|
|
505
|
+
- **窗帘状态与端点记忆**:窗帘动作(0x05) 与位置(0x06) 在内部状态中分离存储,位置只由真正的 0x06 位置帧(含杜亚485位置反馈)更新;仅当位置为 0%/100% 且确认为当前运动方向的目标端点时才标记到达端点并更新 `lastAction/lastDirection`,避免 0x05 帧或“打开中收到0% / 关闭中收到100%”被误判为端点。
|
|
506
|
+
- **开停关停 / 打开-关闭 行为**:
|
|
507
|
+
- 引入 `isMoving` 统一表示“运动过程”,只要执行过开/关且未到端点(0%/100%),物理按键再次点击必发送暂停(3),不会直接翻转方向。
|
|
508
|
+
- 停在 0%/100% 后会进行“端点收敛”:端点上报后不会再多走一次暂停,下一次按键一定是「关着→打开 / 开着→关闭」,到顶/到底后再次点击必然反向(关/开)。
|
|
509
|
+
- 修正 cycle 模式下起点 0%/100% 刚起步时第二下按键误判为翻转的问题;到端点仅在 `isMoving=false && reachedEndpoint=true && endpointPosition=0/100` 时生效,并移除“>50% / <=50% 位置兜底”,中途暂停后的方向完全由 `lastDirection/lastAction` 决定,保证绝对的「开-停-关-停」节奏。
|
|
510
|
+
- **Mesh 透传与日志**:合并重复 close 监听、Mesh 透传热路径日志改为 debug,减少生产环境刷屏;仍兼容 Mesh 动作反馈(0x05) 与位置反馈(0x06),但不再因为偶发“停止/运动中”状态抖动误判为已停止,避免运动过程误触发开/关。
|
|
511
|
+
- **Clowire 场景按键**:Clowire 面板按键在从站开关 / 继电器输出 / Mesh 透传中统一按“场景按钮”处理,每次物理按下都会触发一次事件;运动中的窗帘再次按下必然发送暂停,不会误翻转方向。
|
|
512
|
+
|
|
478
513
|
#### v2.10.16 (2026-02-06)
|
|
479
514
|
|
|
480
515
|
- **按键记忆**:同一帧内若同时上报位置与动作(如 position=100 + action=打开中),以位置为准到端点即按「已停止」记忆,避免被动作覆盖导致下次按键误判。
|
|
@@ -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
|
|
package/nodes/mesh-protocol.js
CHANGED
|
@@ -284,37 +284,6 @@ module.exports = function(_RED) {
|
|
|
284
284
|
mac: null
|
|
285
285
|
};
|
|
286
286
|
|
|
287
|
-
// 逻辑修正:如果 subOp 是窗帘相关类型 (0x05/0x06),处理两种帧格式
|
|
288
|
-
// 1. 标准帧 (Len=4): Addr(2) + Type(1) + Value(1) → 电机到头会上报 06 64(100%) 或 06 00(0%),用于开/关记忆
|
|
289
|
-
// 2. 紧凑帧 (Len=3): Addr(2) + Value(1) (部分旧固件或特定场景)
|
|
290
|
-
if ((subOp === PROTOCOL.MSG_TYPE_CURTAIN || subOp === PROTOCOL.MSG_TYPE_CURTAIN_POS)) {
|
|
291
|
-
const dataLen = frame[3];
|
|
292
|
-
|
|
293
|
-
// 标准帧: 53 80 06 04 [AddrL] [AddrH] [Type] [Value] — Type=0x06 时 Value 即位置 0–100(06 00=0%,06 64=100%)
|
|
294
|
-
if (dataLen === 4 && frame.length >= 8) {
|
|
295
|
-
event.msgType = subOp;
|
|
296
|
-
event.deviceType = frame[6]; // 0x05=动作帧 / 0x06=位置帧(收到位置帧后 entity.type 会为 6,透传节点需按窗帘处理)
|
|
297
|
-
if (subOp === PROTOCOL.MSG_TYPE_CURTAIN) {
|
|
298
|
-
event.action = frame[7];
|
|
299
|
-
} else {
|
|
300
|
-
event.position = frame[7]; // 精准:06 64=100%,06 00=0%
|
|
301
|
-
}
|
|
302
|
-
return event;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// 紧凑帧: 53 80 06 03 [AddrL] [AddrH] [Value]
|
|
306
|
-
if (dataLen === 3 && frame.length >= 7) {
|
|
307
|
-
event.msgType = subOp;
|
|
308
|
-
event.deviceType = PROTOCOL.DEVICE_TYPE_CURTAIN; // 隐式推断
|
|
309
|
-
if (subOp === PROTOCOL.MSG_TYPE_CURTAIN) {
|
|
310
|
-
event.action = frame[6];
|
|
311
|
-
} else {
|
|
312
|
-
event.position = frame[6];
|
|
313
|
-
}
|
|
314
|
-
return event;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
287
|
// 解析数据部分(从第6字节开始)
|
|
319
288
|
let offset = 6;
|
|
320
289
|
while (offset < frame.length - 1) { // 最后一字节是校验
|