node-red-contrib-knx-ultimate 8.1.1 → 8.3.0
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/CHANGELOG.md +13 -0
- package/nodes/commonFunctions.js +13 -151
- package/nodes/knxUltimate-config.html +96 -158
- package/nodes/knxUltimate-config.js +41 -51
- package/nodes/knxUltimate.html +4 -0
- package/nodes/knxUltimateUtility.html +7 -0
- package/nodes/knxUltimateViewer.html +3 -0
- package/nodes/knxUltimateViewer.js +3 -1
- package/nodes/locales/de/knxUltimate-config.html +8 -5
- package/nodes/locales/de/knxUltimate-config.json +22 -20
- package/nodes/locales/de/knxUltimate.html +2 -0
- package/nodes/locales/de/knxUltimateUtility.html +6 -0
- package/nodes/locales/de/knxUltimateViewer.html +2 -0
- package/nodes/locales/en/knxUltimate-config.html +8 -5
- package/nodes/locales/en/knxUltimate-config.json +22 -20
- package/nodes/locales/en/knxUltimate.html +2 -0
- package/nodes/locales/en/knxUltimateUtility.html +6 -0
- package/nodes/locales/en/knxUltimateViewer.html +2 -0
- package/nodes/locales/es/knxUltimate-config.html +12 -6
- package/nodes/locales/es/knxUltimate-config.json +22 -20
- package/nodes/locales/es/knxUltimate.html +2 -0
- package/nodes/locales/es/knxUltimateUtility.html +6 -0
- package/nodes/locales/es/knxUltimateViewer.html +2 -0
- package/nodes/locales/fr/knxUltimate-config.html +10 -7
- package/nodes/locales/fr/knxUltimate-config.json +22 -20
- package/nodes/locales/fr/knxUltimate.html +2 -0
- package/nodes/locales/fr/knxUltimateUtility.html +6 -0
- package/nodes/locales/fr/knxUltimateViewer.html +2 -0
- package/nodes/locales/it/knxUltimate-config.html +8 -5
- package/nodes/locales/it/knxUltimate-config.json +22 -20
- package/nodes/locales/it/knxUltimate.html +2 -0
- package/nodes/locales/it/knxUltimateUtility.html +6 -0
- package/nodes/locales/it/knxUltimateViewer.html +2 -0
- package/nodes/locales/zh-CN/knxUltimate-config.html +8 -5
- package/nodes/locales/zh-CN/knxUltimate-config.json +22 -20
- package/nodes/locales/zh-CN/knxUltimate.html +2 -0
- package/nodes/locales/zh-CN/knxUltimateUtility.html +6 -0
- package/nodes/locales/zh-CN/knxUltimateViewer.html +2 -0
- package/nodes/utils/fileStorage.js +376 -0
- package/nodes/utils/knxUtilityProfiles/runtime/autoresponder.js +15 -19
- package/nodes/utils/knxUtilityProfiles/runtime/logger.js +7 -4
- package/nodes/utils/knxUtilityProfiles/runtime/scenecontroller.js +12 -44
- package/nodes/utils/knxViewerHistory.js +49 -77
- package/package.json +2 -2
- package/resources/htmlUtils.js +24 -2
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"discovering": "正在发现...",
|
|
20
20
|
"interfaces_found": "个接口已发现。",
|
|
21
21
|
"discovery_failed": "发现失败。",
|
|
22
|
-
"weinzierl_ack_autoenabled": "检测到 Weinzierl 接口:已自动启用 '
|
|
22
|
+
"weinzierl_ack_autoenabled": "检测到 Weinzierl 接口:已自动启用 '抑制总线 ACK 请求'。你可以在「高级」选项卡中关闭它。",
|
|
23
23
|
"address": "地址",
|
|
24
24
|
"iface_auto": "自动",
|
|
25
25
|
"iface_manual": "手动输入接口名称",
|
|
@@ -63,15 +63,20 @@
|
|
|
63
63
|
"configuration": "配置",
|
|
64
64
|
"advanced": "高级",
|
|
65
65
|
"ets_import": "ETS 导入",
|
|
66
|
-
"
|
|
66
|
+
"file_cache": "文件缓存控制"
|
|
67
67
|
},
|
|
68
68
|
"advanced": {
|
|
69
69
|
"knx_phy_addr": "KNX 物理地址",
|
|
70
70
|
"show_date_status": "在状态中显示上次更新日期和时间",
|
|
71
71
|
"show_device_status": "在状态中显示设备的名称 (需要 \"监听所有组地址\" )",
|
|
72
72
|
"show_datapoint_status": "在状态中显示数据类型",
|
|
73
|
-
"suppress_ack": "
|
|
74
|
-
"suppress_ack_help": "
|
|
73
|
+
"suppress_ack": "抑制总线 ACK 请求",
|
|
74
|
+
"suppress_ack_help": "总线 ACK。勾选后,发送到 KNX 总线的报文(Tunnel UDP、Multicast 和 Serial FT1.2)不再要求接收设备确认。在 Tunnel UDP 且“等待隧道 ACK”为“自动”时,节点也不再等待接口的确认。仅在接口需要时启用,例如某些旧的或 Weinzierl KNX/IP 接口、eibPORT 和 Loxone。默认:不勾选。",
|
|
75
|
+
"wait_tunneling_ack": "等待隧道 ACK(Tunnel UDP)",
|
|
76
|
+
"wait_tunneling_ack_auto": "自动(仅在未抑制总线 ACK 请求时)",
|
|
77
|
+
"wait_tunneling_ack_yes": "是(重发一次,然后断开)",
|
|
78
|
+
"wait_tunneling_ack_no": "否",
|
|
79
|
+
"wait_tunneling_ack_help": "接口 ACK。使用 Tunnel UDP 时,节点等待 KNX/IP 接口确认每条报文:未被确认的报文会再发送一次,然后关闭并重新建立连接。“自动”仅在未勾选“抑制总线 ACK 请求”时等待,与以前的版本相同。“是”始终等待,“否”从不等待。eibPORT 和 Loxone 网关需要同时勾选“抑制总线 ACK 请求”并选择“是”。",
|
|
75
80
|
"ignoreTelegramsWithRepeatedFlag": "抑制重复的 (R-Flag) 来自总线的电报",
|
|
76
81
|
"enable_flow_bubbles": "启用 flow bubbles 插件",
|
|
77
82
|
"enable_flow_bubbles_help": "在编辑器中为 KNX Device 节点显示实时状态气泡。适合调试和调试期间快速观察状态。",
|
|
@@ -129,22 +134,19 @@
|
|
|
129
134
|
"tunnel_user_password": "隧道用户密码",
|
|
130
135
|
"ga_list_label": "ETS 组地址列表"
|
|
131
136
|
},
|
|
132
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"
|
|
135
|
-
"
|
|
136
|
-
"
|
|
137
|
-
"
|
|
138
|
-
"
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"
|
|
142
|
-
"
|
|
143
|
-
"
|
|
144
|
-
"
|
|
145
|
-
"reveal_keyring_button": "显示",
|
|
146
|
-
"reveal_keyring_loading": "正在解码密钥环,请稍候...",
|
|
147
|
-
"reveal_keyring_fail": "无法解码密钥环。请检查密钥环文件和通用密码。"
|
|
137
|
+
"file_cache": {
|
|
138
|
+
"mode": "磁盘磨损管理",
|
|
139
|
+
"mode_freshness": "新鲜度优先",
|
|
140
|
+
"mode_balanced": "均衡",
|
|
141
|
+
"mode_disabled": "禁用写入",
|
|
142
|
+
"mode_custom": "自定义间隔",
|
|
143
|
+
"interval": "写入间隔",
|
|
144
|
+
"unit_seconds": "秒",
|
|
145
|
+
"unit_minutes": "分钟",
|
|
146
|
+
"unit_hours": "小时",
|
|
147
|
+
"unit_days": "天",
|
|
148
|
+
"disabled_tip": "已禁用磁盘写入(KNX 网关的文件缓存控制):每次重启 Node-RED 时,所有当前 GA 值以及 KNX Ultimate 节点保存的数据都将丢失。",
|
|
149
|
+
"description": "控制 KNX Ultimate 将其文件(GA 值、Scene Controller 场景、AutoResponder 值、Logger 文件、Viewer 历史记录)写入磁盘的频率,以减少 SD 卡的磨损。仅当文件内容发生变化时才会写入。日志不受影响:请使用日志级别进行控制。<br/><b>新鲜度优先</b>:每 5 秒保存一次更改。<br/><b>均衡</b>:仅在网关连接或断开时,以及 Node-RED 停止或网关重新部署时保存更改。<br/><b>禁用写入</b>:不向磁盘写入任何内容。<br/><b>自定义间隔</b>:按所选间隔(5 秒到 30 天)保存更改,并在 Node-RED 停止或网关重新部署时保存。"
|
|
148
150
|
}
|
|
149
151
|
}
|
|
150
152
|
}
|
|
@@ -54,6 +54,8 @@
|
|
|
54
54
|
| 响应读报文 | 收到读报文时向流程输出(用于自定义返回值)。|
|
|
55
55
|
| Multiply/Decimals/Negatives | 数值缩放、四舍五入、小数与负值处理(仅数值)。|
|
|
56
56
|
|
|
57
|
+
> 💾 **当初始化连接/重新连接的时候发送读取消息**(从文件读取)所用的 GA 值何时写入磁盘由 KNX 网关的**文件缓存控制**决定。选择**禁用写入**时,这些数据仅保存在内存中,每次重启 Node-RED 都会丢失。 参见 KNX 网关的**文件缓存控制**选项卡。
|
|
58
|
+
|
|
57
59
|
<br/>
|
|
58
60
|
<br/>
|
|
59
61
|
|
|
@@ -163,6 +163,8 @@ msg = {
|
|
|
163
163
|
|
|
164
164
|
## AutoResponder
|
|
165
165
|
|
|
166
|
+
> 💾 已存储的值何时写入磁盘由 KNX 网关的**文件缓存控制**决定。选择**禁用写入**时,这些数据仅保存在内存中,每次重启 Node-RED 都会丢失。 参见 KNX 网关的**文件缓存控制**选项卡。
|
|
167
|
+
|
|
166
168
|
此节点将响应KNX总线的读取请求。
|
|
167
169
|
|
|
168
170
|
节点记录所有传输到KNX总线并将值存储在内存中的电报。
|
|
@@ -503,6 +505,8 @@ global.set("KNXContextBanana_WRITE", toSend);
|
|
|
503
505
|
|
|
504
506
|
## 日志记录
|
|
505
507
|
|
|
508
|
+
> 💾 **发送 payload 并保存到文件**的 XML 文件何时写入磁盘由 KNX 网关的**文件缓存控制**决定。选择**禁用写入**时,这些数据仅保存在内存中,每次重启 Node-RED 都会丢失。 参见 KNX 网关的**文件缓存控制**选项卡。
|
|
509
|
+
|
|
506
510
|
### Logger(日志)
|
|
507
511
|
|
|
508
512
|
Logger 节点会记录所有报文,并输出一份与 ETS Bus Monitor 兼容的 XML 文件。
|
|
@@ -762,6 +766,8 @@ return msg;
|
|
|
762
766
|
|
|
763
767
|
## 场景控制
|
|
764
768
|
|
|
769
|
+
> 💾 已保存的场景何时写入磁盘由 KNX 网关的**文件缓存控制**决定。选择**禁用写入**时,这些数据仅保存在内存中,每次重启 Node-RED 都会丢失。 参见 KNX 网关的**文件缓存控制**选项卡。
|
|
770
|
+
|
|
765
771
|
### Scene Controller(场景控制器)
|
|
766
772
|
|
|
767
773
|
该节点与 KNX 场景控制器一致:可以保存和召回场景。
|
|
@@ -19,6 +19,8 @@ Read 请求也会保存在 24 小时历史记录中,但不显示上一个值
|
|
|
19
19
|
|
|
20
20
|
运行中的 Viewer 节点会自动将历史记录保存到 `userDir/knxultimatestorage/viewer` 目录下的文件中,并按 Viewer 和网关分别存储。超过 24 小时的变化记录会自动清除。Node-RED 重启后,Viewer 会恢复仍在此时间范围内的历史记录。
|
|
21
21
|
|
|
22
|
+
> 💾 历史记录文件何时写入磁盘由 KNX 网关的**文件缓存控制**决定。选择**禁用写入**时,这些数据仅保存在内存中,每次重启 Node-RED 都会丢失。 参见 KNX 网关的**文件缓存控制**选项卡。
|
|
23
|
+
|
|
22
24
|
更新后的 Viewer 部署并运行后才会开始记录,不会补录此前的总线流量。
|
|
23
25
|
|
|
24
26
|
## 流程输出
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// File Cache Control: every file written by this package goes through this module.
|
|
2
|
+
// The Winston log is the only exception, as it is governed by the log level.
|
|
3
|
+
//
|
|
4
|
+
// Each gateway (knxUltimate-config) owns one storage. Nodes write to it as usual,
|
|
5
|
+
// but the data is kept in memory and committed to disk according to the
|
|
6
|
+
// gateway's "disk-wearing" mode:
|
|
7
|
+
// freshness - pending changes are committed every 5 seconds
|
|
8
|
+
// balanced - pending changes are committed when the gateway connects or disconnects
|
|
9
|
+
// custom - pending changes are committed at a user defined interval (5 s .. 30 days)
|
|
10
|
+
// disabled - nothing is ever written: the data lives in memory until Node-RED restarts
|
|
11
|
+
// Reads always see pending data, and write failures never reach the nodes:
|
|
12
|
+
// whatever the mode, a node behaves as if its data had been saved.
|
|
13
|
+
const fs = require('fs')
|
|
14
|
+
const path = require('path')
|
|
15
|
+
|
|
16
|
+
const MODES = ['freshness', 'balanced', 'disabled', 'custom']
|
|
17
|
+
const FRESHNESS_INTERVAL_MS = 5000
|
|
18
|
+
const MIN_INTERVAL_SECONDS = 5
|
|
19
|
+
const MAX_INTERVAL_SECONDS = 30 * 24 * 60 * 60
|
|
20
|
+
const UNIT_SECONDS = { s: 1, m: 60, h: 60 * 60, d: 24 * 60 * 60 }
|
|
21
|
+
// setInterval cannot wait longer than 2^31-1 ms (about 24.8 days): longer
|
|
22
|
+
// intervals are checked every hour instead.
|
|
23
|
+
const MAX_TIMER_MS = 2 ** 31 - 1
|
|
24
|
+
const LONG_INTERVAL_CHECK_MS = 60 * 60 * 1000
|
|
25
|
+
// Appended data (e.g. the Viewer history) can grow without limits between two
|
|
26
|
+
// commits. Past this size it is committed early or, when writing is disabled,
|
|
27
|
+
// its oldest chunks are dropped.
|
|
28
|
+
const MAX_PENDING_APPEND_BYTES = 8 * 1024 * 1024
|
|
29
|
+
// After a gateway redeploy, a storage in "disabled" mode hands its data over to
|
|
30
|
+
// the next instance of the same gateway, so only a Node-RED restart loses it.
|
|
31
|
+
const HANDOVER_TTL_MS = 60 * 1000
|
|
32
|
+
const NEWLINE = Buffer.from('\n')
|
|
33
|
+
|
|
34
|
+
const handovers = new Map()
|
|
35
|
+
|
|
36
|
+
function resolveSettings ({ mode, interval, intervalUnit } = {}) {
|
|
37
|
+
const resolvedMode = MODES.includes(mode) ? mode : 'freshness'
|
|
38
|
+
// Missing values (gateways saved by older versions) default to 10 minutes, as in the editor.
|
|
39
|
+
const amount = interval === '' || interval === null || interval === undefined ? NaN : Number(interval)
|
|
40
|
+
let seconds = amount * (UNIT_SECONDS[intervalUnit] || UNIT_SECONDS.m)
|
|
41
|
+
if (!Number.isFinite(seconds)) seconds = 10 * 60
|
|
42
|
+
seconds = Math.min(MAX_INTERVAL_SECONDS, Math.max(MIN_INTERVAL_SECONDS, Math.round(seconds)))
|
|
43
|
+
let intervalMs = 0
|
|
44
|
+
if (resolvedMode === 'freshness') intervalMs = FRESHNESS_INTERVAL_MS
|
|
45
|
+
if (resolvedMode === 'custom') intervalMs = seconds * 1000
|
|
46
|
+
return { mode: resolvedMode, intervalMs }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toBuffer (data) {
|
|
50
|
+
const value = typeof data === 'function' ? data() : data
|
|
51
|
+
return Buffer.isBuffer(value) ? value : Buffer.from(String(value === undefined || value === null ? '' : value))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function notFound (filePath) {
|
|
55
|
+
const error = new Error(`ENOENT: no such file or directory, open '${filePath}'`)
|
|
56
|
+
error.code = 'ENOENT'
|
|
57
|
+
error.path = filePath
|
|
58
|
+
return error
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readDisk (filePath) {
|
|
62
|
+
try { return fs.readFileSync(filePath) } catch (error) {
|
|
63
|
+
if (error.code === 'ENOENT') return null
|
|
64
|
+
throw error
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function endsWithNewline (filePath) {
|
|
69
|
+
let descriptor
|
|
70
|
+
try {
|
|
71
|
+
descriptor = fs.openSync(filePath, 'r')
|
|
72
|
+
const { size } = fs.fstatSync(descriptor)
|
|
73
|
+
if (size === 0) return true
|
|
74
|
+
const last = Buffer.alloc(1)
|
|
75
|
+
fs.readSync(descriptor, last, 0, 1, size - 1)
|
|
76
|
+
return last[0] === 10
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error.code === 'ENOENT') return true
|
|
79
|
+
throw error
|
|
80
|
+
} finally {
|
|
81
|
+
if (descriptor !== undefined) fs.closeSync(descriptor)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The only function of the package that modifies files on disk.
|
|
86
|
+
function commitFileOperation ({ type, filePath, data, options = {} }) {
|
|
87
|
+
if (type === 'delete') {
|
|
88
|
+
fs.rmSync(filePath, { force: true })
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: options.dirMode })
|
|
92
|
+
if (type === 'write') {
|
|
93
|
+
// Replace through a sibling file, so a crash never leaves a truncated file.
|
|
94
|
+
const temporary = filePath + '.knxtmp'
|
|
95
|
+
fs.writeFileSync(temporary, toBuffer(data), { mode: options.mode })
|
|
96
|
+
fs.renameSync(temporary, filePath)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
let chunk = toBuffer(data)
|
|
100
|
+
// A previously interrupted append leaves half a line: keep it on its own
|
|
101
|
+
// line, so the data appended now stays readable.
|
|
102
|
+
if (options.newlineDelimited && !endsWithNewline(filePath)) chunk = Buffer.concat([NEWLINE, chunk])
|
|
103
|
+
fs.appendFileSync(filePath, chunk, { mode: options.mode })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
class FileStorage {
|
|
107
|
+
constructor ({ ownerId, mode, interval, intervalUnit, log } = {}) {
|
|
108
|
+
const settings = resolveSettings({ mode, interval, intervalUnit })
|
|
109
|
+
this.ownerId = ownerId
|
|
110
|
+
this.mode = settings.mode
|
|
111
|
+
this.intervalMs = settings.intervalMs
|
|
112
|
+
this.log = typeof log === 'function' ? log : () => {}
|
|
113
|
+
// Without an owner the storage commits immediately and reports errors to
|
|
114
|
+
// the caller. It is used by nodes without a gateway and after a close.
|
|
115
|
+
this.direct = mode === 'direct'
|
|
116
|
+
this.closed = false
|
|
117
|
+
this.pending = new Map()
|
|
118
|
+
this.pendingAppendBytes = 0
|
|
119
|
+
this.sequence = 0
|
|
120
|
+
this.lastError = ''
|
|
121
|
+
this.lastFailureAt = 0
|
|
122
|
+
this.timer = null
|
|
123
|
+
this.adoptHandover()
|
|
124
|
+
this.lastFlushAt = Date.now()
|
|
125
|
+
if (!this.direct && this.intervalMs > 0) {
|
|
126
|
+
this.timer = this.intervalMs <= MAX_TIMER_MS
|
|
127
|
+
? setInterval(() => this.flush(), this.intervalMs)
|
|
128
|
+
: setInterval(() => { if (Date.now() - this.lastFlushAt >= this.intervalMs) this.flush() }, LONG_INTERVAL_CHECK_MS)
|
|
129
|
+
if (typeof this.timer?.unref === 'function') this.timer.unref()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
get writable () {
|
|
134
|
+
return this.mode !== 'disabled'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get immediate () {
|
|
138
|
+
return this.direct || this.closed
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
adoptHandover () {
|
|
142
|
+
const now = Date.now()
|
|
143
|
+
for (const [ownerId, handover] of handovers) {
|
|
144
|
+
if (now - handover.closedAt > HANDOVER_TTL_MS) handovers.delete(ownerId)
|
|
145
|
+
}
|
|
146
|
+
const handover = this.ownerId !== undefined ? handovers.get(this.ownerId) : undefined
|
|
147
|
+
if (!handover) return
|
|
148
|
+
handovers.delete(this.ownerId)
|
|
149
|
+
this.pending = handover.pending
|
|
150
|
+
this.pendingAppendBytes = handover.pendingAppendBytes
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Replaces the whole file. `data` can be a function: it is evaluated only
|
|
154
|
+
// when the file is committed or read, so frequent updates cost nothing.
|
|
155
|
+
writeFile (filePath, data, options = {}) {
|
|
156
|
+
this.enqueue(filePath, { kind: 'write', data, options })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
appendFile (filePath, data, options = {}) {
|
|
160
|
+
if (this.immediate) {
|
|
161
|
+
if (this.writable) commitFileOperation({ type: 'append', filePath, data, options })
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
const chunk = toBuffer(data)
|
|
165
|
+
const entry = this.pending.get(filePath)
|
|
166
|
+
if (entry && entry.kind === 'append') {
|
|
167
|
+
entry.chunks.push(chunk)
|
|
168
|
+
entry.bytes += chunk.length
|
|
169
|
+
this.pendingAppendBytes += chunk.length
|
|
170
|
+
} else if (entry) {
|
|
171
|
+
// Appending to replaced or deleted content is a replacement too.
|
|
172
|
+
const base = entry.kind === 'write' ? toBuffer(entry.data) : Buffer.alloc(0)
|
|
173
|
+
this.pending.set(filePath, { kind: 'write', data: Buffer.concat([base, chunk]), options: { ...entry.options, ...options } })
|
|
174
|
+
} else {
|
|
175
|
+
this.pending.set(filePath, { kind: 'append', chunks: [chunk], bytes: chunk.length, options, sequence: ++this.sequence })
|
|
176
|
+
this.pendingAppendBytes += chunk.length
|
|
177
|
+
}
|
|
178
|
+
this.enforceAppendBudget()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
deleteFile (filePath) {
|
|
182
|
+
// A file that exists only in memory has nothing to delete on disk.
|
|
183
|
+
if (!this.immediate && !fs.existsSync(filePath)) {
|
|
184
|
+
this.forget(filePath)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
this.enqueue(filePath, { kind: 'delete', options: {} })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
enqueue (filePath, entry) {
|
|
191
|
+
if (this.immediate) {
|
|
192
|
+
if (this.writable) commitFileOperation({ type: entry.kind, filePath, data: entry.data, options: entry.options })
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
this.forget(filePath)
|
|
196
|
+
this.pending.set(filePath, entry)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
forget (filePath) {
|
|
200
|
+
const entry = this.pending.get(filePath)
|
|
201
|
+
if (entry && entry.kind === 'append') this.pendingAppendBytes -= entry.bytes
|
|
202
|
+
this.pending.delete(filePath)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
enforceAppendBudget () {
|
|
206
|
+
if (this.pendingAppendBytes <= MAX_PENDING_APPEND_BYTES) return
|
|
207
|
+
if (this.writable && Date.now() - this.lastFailureAt > FRESHNESS_INTERVAL_MS) this.flush()
|
|
208
|
+
// Still too much data (writing disabled or failing): drop the oldest chunks.
|
|
209
|
+
while (this.pendingAppendBytes > MAX_PENDING_APPEND_BYTES) {
|
|
210
|
+
let oldest
|
|
211
|
+
for (const [filePath, entry] of this.pending) {
|
|
212
|
+
if (entry.kind === 'append' && (!oldest || entry.sequence < oldest.entry.sequence)) oldest = { filePath, entry }
|
|
213
|
+
}
|
|
214
|
+
if (!oldest) return
|
|
215
|
+
const dropped = oldest.entry.chunks.shift()
|
|
216
|
+
oldest.entry.bytes -= dropped.length
|
|
217
|
+
this.pendingAppendBytes -= dropped.length
|
|
218
|
+
if (oldest.entry.chunks.length === 0) this.pending.delete(oldest.filePath)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Content of a pending append: what is on disk followed by the pending chunks.
|
|
223
|
+
appendedContent (filePath, entry) {
|
|
224
|
+
const disk = readDisk(filePath) || Buffer.alloc(0)
|
|
225
|
+
const parts = [disk]
|
|
226
|
+
if (entry.options.newlineDelimited && disk.length > 0 && disk[disk.length - 1] !== 10) parts.push(NEWLINE)
|
|
227
|
+
return Buffer.concat(parts.concat(entry.chunks))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
readFile (filePath, encoding) {
|
|
231
|
+
const entry = this.pending.get(filePath)
|
|
232
|
+
let content
|
|
233
|
+
if (!entry) return fs.readFileSync(filePath, encoding)
|
|
234
|
+
if (entry.kind === 'delete') throw notFound(filePath)
|
|
235
|
+
if (entry.kind === 'write') content = toBuffer(entry.data)
|
|
236
|
+
else content = this.appendedContent(filePath, entry)
|
|
237
|
+
return encoding ? content.toString(encoding) : content
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
exists (filePath) {
|
|
241
|
+
const entry = this.pending.get(filePath)
|
|
242
|
+
if (entry) return entry.kind !== 'delete'
|
|
243
|
+
return fs.existsSync(filePath)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// File names in a directory, pending files included.
|
|
247
|
+
listDirectory (directory) {
|
|
248
|
+
const names = new Set()
|
|
249
|
+
try {
|
|
250
|
+
fs.readdirSync(directory).forEach(name => names.add(name))
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error.code !== 'ENOENT') throw error
|
|
253
|
+
}
|
|
254
|
+
for (const [filePath, entry] of this.pending) {
|
|
255
|
+
if (path.dirname(filePath) !== directory) continue
|
|
256
|
+
if (entry.kind === 'delete') names.delete(path.basename(filePath))
|
|
257
|
+
else names.add(path.basename(filePath))
|
|
258
|
+
}
|
|
259
|
+
return Array.from(names)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// A read-only snapshot of a file, pending data included, for random-access
|
|
263
|
+
// reads of large files. Data committed or added later is not visible.
|
|
264
|
+
async openSnapshot (filePath) {
|
|
265
|
+
const entry = this.pending.get(filePath)
|
|
266
|
+
if (entry && entry.kind === 'delete') throw notFound(filePath)
|
|
267
|
+
let handle = null
|
|
268
|
+
let diskSize = 0
|
|
269
|
+
let memory = Buffer.alloc(0)
|
|
270
|
+
if (entry && entry.kind === 'write') {
|
|
271
|
+
memory = toBuffer(entry.data)
|
|
272
|
+
} else {
|
|
273
|
+
try {
|
|
274
|
+
handle = await fs.promises.open(filePath, 'r')
|
|
275
|
+
diskSize = (await handle.stat()).size
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error.code !== 'ENOENT' || !entry) throw error
|
|
278
|
+
}
|
|
279
|
+
if (entry) {
|
|
280
|
+
const parts = []
|
|
281
|
+
if (handle && diskSize > 0 && entry.options.newlineDelimited) {
|
|
282
|
+
const last = Buffer.alloc(1)
|
|
283
|
+
await handle.read(last, 0, 1, diskSize - 1)
|
|
284
|
+
if (last[0] !== 10) parts.push(NEWLINE)
|
|
285
|
+
}
|
|
286
|
+
memory = Buffer.concat(parts.concat(entry.chunks))
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
size: diskSize + memory.length,
|
|
291
|
+
async read (buffer, offset, length, position) {
|
|
292
|
+
let bytesRead = 0
|
|
293
|
+
if (handle && position < diskSize) {
|
|
294
|
+
const result = await handle.read(buffer, offset, Math.min(length, diskSize - position), position)
|
|
295
|
+
bytesRead = result.bytesRead
|
|
296
|
+
}
|
|
297
|
+
const memoryStart = Math.max(0, position + bytesRead - diskSize)
|
|
298
|
+
if (bytesRead < length && position + bytesRead >= diskSize) {
|
|
299
|
+
bytesRead += memory.copy(buffer, offset + bytesRead, memoryStart, memoryStart + length - bytesRead)
|
|
300
|
+
}
|
|
301
|
+
return { bytesRead }
|
|
302
|
+
},
|
|
303
|
+
async close () {
|
|
304
|
+
if (handle) await handle.close()
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Commits every pending change. Failed changes stay pending for the next attempt.
|
|
310
|
+
flush () {
|
|
311
|
+
this.lastFlushAt = Date.now()
|
|
312
|
+
if (!this.writable || this.pending.size === 0) return
|
|
313
|
+
for (const [filePath, entry] of Array.from(this.pending)) {
|
|
314
|
+
try {
|
|
315
|
+
if (entry.kind === 'append') {
|
|
316
|
+
commitFileOperation({ type: 'append', filePath, data: Buffer.concat(entry.chunks), options: entry.options })
|
|
317
|
+
} else {
|
|
318
|
+
commitFileOperation({ type: entry.kind, filePath, data: entry.data, options: entry.options })
|
|
319
|
+
}
|
|
320
|
+
this.forget(filePath)
|
|
321
|
+
} catch (error) {
|
|
322
|
+
this.lastFailureAt = Date.now()
|
|
323
|
+
const message = `File Cache Control: unable to save ${filePath}: ${error.message}`
|
|
324
|
+
if (this.lastError !== message) this.log('error', message)
|
|
325
|
+
this.lastError = message
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Called by the gateway on every connection and disconnection.
|
|
331
|
+
handleGatewayEvent () {
|
|
332
|
+
if (this.mode === 'balanced') this.flush()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
close () {
|
|
336
|
+
if (this.closed) return
|
|
337
|
+
if (this.timer !== null) clearInterval(this.timer)
|
|
338
|
+
this.timer = null
|
|
339
|
+
if (this.writable) {
|
|
340
|
+
this.flush()
|
|
341
|
+
} else if (this.ownerId !== undefined && this.pending.size > 0) {
|
|
342
|
+
// Serialize now: the data of a closed node must not be kept alive.
|
|
343
|
+
for (const entry of this.pending.values()) {
|
|
344
|
+
if (entry.kind === 'write') entry.data = toBuffer(entry.data)
|
|
345
|
+
}
|
|
346
|
+
handovers.set(this.ownerId, { pending: this.pending, pendingAppendBytes: this.pendingAppendBytes, closedAt: Date.now() })
|
|
347
|
+
}
|
|
348
|
+
this.pending = new Map()
|
|
349
|
+
this.pendingAppendBytes = 0
|
|
350
|
+
// Late writes (e.g. nodes closing after their gateway) are committed at once.
|
|
351
|
+
this.closed = true
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const directStorage = new FileStorage({ mode: 'direct' })
|
|
356
|
+
|
|
357
|
+
function createFileStorage (options) {
|
|
358
|
+
return new FileStorage(options)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// The storage of a gateway node, or the direct one when there is no gateway.
|
|
362
|
+
function storageOf (gateway) {
|
|
363
|
+
return gateway && gateway.fileStorage instanceof FileStorage ? gateway.fileStorage : directStorage
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
module.exports = {
|
|
367
|
+
createFileStorage,
|
|
368
|
+
storageOf,
|
|
369
|
+
resolveSettings,
|
|
370
|
+
directStorage,
|
|
371
|
+
FileStorage,
|
|
372
|
+
MODES,
|
|
373
|
+
MIN_INTERVAL_SECONDS,
|
|
374
|
+
MAX_INTERVAL_SECONDS,
|
|
375
|
+
MAX_PENDING_APPEND_BYTES
|
|
376
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Private KNX Utility profile. Independent of the legacy palette entry point.
|
|
2
2
|
const loggerClass = require('../../sysLogger')
|
|
3
|
+
const { storageOf } = require('../../fileStorage')
|
|
3
4
|
|
|
4
5
|
module.exports = function (RED) {
|
|
5
6
|
const dptlib = require('knxultimate').dptlib
|
|
6
|
-
const fs = require('fs')
|
|
7
7
|
const path = require('path')
|
|
8
8
|
|
|
9
9
|
// msg is:
|
|
@@ -51,7 +51,6 @@ module.exports = function (RED) {
|
|
|
51
51
|
node.inputRBE = 'false' // Apply or not RBE to the input (Messages coming from BUS)
|
|
52
52
|
node.exposedGAs = []
|
|
53
53
|
node.commandText = [] // Raw list Respond To
|
|
54
|
-
node.timerSaveExposedGAs = null
|
|
55
54
|
|
|
56
55
|
const pushStatus = (status) => {
|
|
57
56
|
if (!status) return
|
|
@@ -86,21 +85,24 @@ module.exports = function (RED) {
|
|
|
86
85
|
// }
|
|
87
86
|
}
|
|
88
87
|
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
// The gateway's File Cache Control decides when the values reach the disk.
|
|
89
|
+
const storage = storageOf(node.serverKNX)
|
|
90
|
+
const persistFile = path.join(node.serverKNX.userDir, 'knxpersistvalues', 'knxpersist' + node.id + '.json')
|
|
91
|
+
node.saveExposedGAs = (snapshot = false) => {
|
|
91
92
|
try {
|
|
92
93
|
// An empty configured list must also replace its previous saved values.
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
// A snapshot is serialized now, as the list is about to be discarded.
|
|
95
|
+
const serialize = () => JSON.stringify(node.exposedGAs)
|
|
96
|
+
storage.writeFile(persistFile, snapshot ? serialize() : serialize)
|
|
95
97
|
} catch (err) {
|
|
96
|
-
if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.error('knxUltimateAutoResponder: unable to write peristent values to the file ' +
|
|
98
|
+
if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.error('knxUltimateAutoResponder: unable to write peristent values to the file ' + persistFile + ' ' + err.message)
|
|
97
99
|
}
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
node.loadExposedGAs = () => {
|
|
101
|
-
const sFile =
|
|
103
|
+
const sFile = persistFile
|
|
102
104
|
try {
|
|
103
|
-
node.exposedGAs = JSON.parse(
|
|
105
|
+
node.exposedGAs = JSON.parse(storage.readFile(sFile, 'utf8'))
|
|
104
106
|
} catch (err) {
|
|
105
107
|
node.exposedGAs = []
|
|
106
108
|
if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.warn('knxUltimateAutoResponder: unable to read peristent file ' + sFile + ' ' + err.message)
|
|
@@ -183,6 +185,7 @@ module.exports = function (RED) {
|
|
|
183
185
|
// inside the loop discards persisted payloads belonging to later entries;
|
|
184
186
|
// skipping this step for [] leaves removed addresses able to respond.
|
|
185
187
|
node.exposedGAs = node.exposedGAs.filter(ga => ga.enabled === true)
|
|
188
|
+
node.saveExposedGAs()
|
|
186
189
|
|
|
187
190
|
// This function is called by the knx-ultimate config node, to output a msg.payload.
|
|
188
191
|
node.handleSend = msg => {
|
|
@@ -203,7 +206,9 @@ module.exports = function (RED) {
|
|
|
203
206
|
updateStatus({ fill: 'red', shape: 'dot', text: 'const decodedPayload = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(oGa.dpt)); ' + error.message, payload: '', dpt: '', devicename: '' })
|
|
204
207
|
if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.error(`knxUltimateAutoResponder: const decodedPayload = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(oGa.dpt)); ${error.stack}`)
|
|
205
208
|
}
|
|
209
|
+
const changed = JSON.stringify(oGa.payload) !== JSON.stringify(decodedPayload)
|
|
206
210
|
oGa.payload = decodedPayload
|
|
211
|
+
if (changed) node.saveExposedGAs()
|
|
207
212
|
}
|
|
208
213
|
} else {
|
|
209
214
|
try {
|
|
@@ -238,11 +243,7 @@ module.exports = function (RED) {
|
|
|
238
243
|
})
|
|
239
244
|
|
|
240
245
|
node.on('close', function (done) {
|
|
241
|
-
|
|
242
|
-
if (node.timerSaveExposedGAs !== null) clearInterval(node.timerSaveExposedGAs)
|
|
243
|
-
node.saveExposedGAs()
|
|
244
|
-
} catch (error) {
|
|
245
|
-
}
|
|
246
|
+
node.saveExposedGAs(true)
|
|
246
247
|
|
|
247
248
|
node.exposedGAs = []
|
|
248
249
|
if (node.serverKNX) {
|
|
@@ -256,11 +257,6 @@ module.exports = function (RED) {
|
|
|
256
257
|
node.serverKNX.removeClient(node)
|
|
257
258
|
node.serverKNX.addClient(node)
|
|
258
259
|
}
|
|
259
|
-
// Start persistence only after configuration and close handling are ready.
|
|
260
|
-
// Invalid JSON must not leave an interval alive on an inactive profile.
|
|
261
|
-
node.timerSaveExposedGAs = setInterval(async () => {
|
|
262
|
-
await node.saveExposedGAs()
|
|
263
|
-
}, 5000)
|
|
264
260
|
}
|
|
265
261
|
RED.nodes.registerType('knxUltimateAutoResponder', knxUltimateAutoResponder)
|
|
266
262
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Private KNX Utility profile. Independent of the legacy palette entry point.
|
|
2
2
|
const createProfileTimers = require('./profileTimers')
|
|
3
|
-
const
|
|
3
|
+
const { storageOf } = require('../../fileStorage')
|
|
4
4
|
|
|
5
5
|
module.exports = function (RED) {
|
|
6
6
|
function knxUltimateLogger (config) {
|
|
@@ -80,11 +80,14 @@ module.exports = function (RED) {
|
|
|
80
80
|
return header + lines.join('') + stop + closing
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// The gateway's File Cache Control decides when the file reaches the disk.
|
|
84
|
+
const storage = storageOf(node.serverKNX)
|
|
85
|
+
|
|
83
86
|
const readExistingTelegramLines = () => {
|
|
84
87
|
if (node.saveMode !== 'emit_save' || node.filePath === '') return []
|
|
85
|
-
if (!
|
|
88
|
+
if (!storage.exists(node.filePath)) return []
|
|
86
89
|
try {
|
|
87
|
-
const fileContent =
|
|
90
|
+
const fileContent = storage.readFile(node.filePath, 'utf8')
|
|
88
91
|
const lines = fileContent.split(/\r?\n/).map(l => l + '\n') // keep newline
|
|
89
92
|
const telegramLines = []
|
|
90
93
|
let inLog = false
|
|
@@ -121,7 +124,7 @@ module.exports = function (RED) {
|
|
|
121
124
|
allLines = allLines.slice(allLines.length - node.maxRowsInETSXML)
|
|
122
125
|
}
|
|
123
126
|
const fileContent = buildXMLFromLines(allLines)
|
|
124
|
-
|
|
127
|
+
storage.writeFile(node.filePath, fileContent)
|
|
125
128
|
statusText = 'Payload ETS sent and saved.'
|
|
126
129
|
} catch (error) {
|
|
127
130
|
node.error(`knxUltimateLogger: error writing file ${node.filePath}: ${error.message}`)
|