node-red-contrib-knx-ultimate 8.1.0 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/nodes/commonFunctions.js +13 -151
  3. package/nodes/knxUltimate-config.html +96 -158
  4. package/nodes/knxUltimate-config.js +64 -58
  5. package/nodes/knxUltimate.html +4 -0
  6. package/nodes/knxUltimateUtility.html +7 -0
  7. package/nodes/knxUltimateViewer.html +3 -0
  8. package/nodes/knxUltimateViewer.js +3 -1
  9. package/nodes/locales/de/knxUltimate-config.html +8 -5
  10. package/nodes/locales/de/knxUltimate-config.json +22 -20
  11. package/nodes/locales/de/knxUltimate.html +2 -0
  12. package/nodes/locales/de/knxUltimateUtility.html +6 -0
  13. package/nodes/locales/de/knxUltimateViewer.html +2 -0
  14. package/nodes/locales/en/knxUltimate-config.html +8 -5
  15. package/nodes/locales/en/knxUltimate-config.json +22 -20
  16. package/nodes/locales/en/knxUltimate.html +2 -0
  17. package/nodes/locales/en/knxUltimateUtility.html +6 -0
  18. package/nodes/locales/en/knxUltimateViewer.html +2 -0
  19. package/nodes/locales/es/knxUltimate-config.html +12 -6
  20. package/nodes/locales/es/knxUltimate-config.json +22 -20
  21. package/nodes/locales/es/knxUltimate.html +2 -0
  22. package/nodes/locales/es/knxUltimateUtility.html +6 -0
  23. package/nodes/locales/es/knxUltimateViewer.html +2 -0
  24. package/nodes/locales/fr/knxUltimate-config.html +10 -7
  25. package/nodes/locales/fr/knxUltimate-config.json +22 -20
  26. package/nodes/locales/fr/knxUltimate.html +2 -0
  27. package/nodes/locales/fr/knxUltimateUtility.html +6 -0
  28. package/nodes/locales/fr/knxUltimateViewer.html +2 -0
  29. package/nodes/locales/it/knxUltimate-config.html +8 -5
  30. package/nodes/locales/it/knxUltimate-config.json +22 -20
  31. package/nodes/locales/it/knxUltimate.html +2 -0
  32. package/nodes/locales/it/knxUltimateUtility.html +6 -0
  33. package/nodes/locales/it/knxUltimateViewer.html +2 -0
  34. package/nodes/locales/zh-CN/knxUltimate-config.html +8 -5
  35. package/nodes/locales/zh-CN/knxUltimate-config.json +22 -20
  36. package/nodes/locales/zh-CN/knxUltimate.html +2 -0
  37. package/nodes/locales/zh-CN/knxUltimateUtility.html +6 -0
  38. package/nodes/locales/zh-CN/knxUltimateViewer.html +2 -0
  39. package/nodes/utils/fileStorage.js +376 -0
  40. package/nodes/utils/knxUtilityProfiles/runtime/autoresponder.js +15 -19
  41. package/nodes/utils/knxUtilityProfiles/runtime/logger.js +7 -4
  42. package/nodes/utils/knxUtilityProfiles/runtime/scenecontroller.js +12 -44
  43. package/nodes/utils/knxViewerHistory.js +49 -77
  44. package/package.json +2 -2
  45. 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 接口:已自动启用 'Suppress ACK request'。你可以在「高级」选项卡中关闭它。",
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
- "utility": "工具"
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": "抑制 ACK 请求 in tunneling mode",
74
- "suppress_ack_help": "上面的选项有助于旧的 KNX/IP 接口兼容性 Warning: the node may not notice the disconnection from the gateway. In this case, use the watchdog in Ethernet+KNX twisted pair mode.",
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
- "utility": {
133
- "gather_debug": "收集调试信息和日志以便排查",
134
- "get_all_used_ga": "获取 KNX 路由过滤器使用的所有 GA",
135
- "read_button": "读取",
136
- "copy_ga_router": "将这些组地址复制到 KNX/IP 路由器的路由表中。",
137
- "clear_persist_label": "清除该网关的持久化 GA 缓存",
138
- "clear_persist_button": "清除",
139
- "clear_persist_confirm": "这将删除该网关的持久化 GA 缓存文件。继续?",
140
- "clear_persist_ok": "删除",
141
- "clear_persist_cancel": "取消",
142
- "clear_persist_done": "已删除持久化 GA 缓存。",
143
- "clear_persist_fail": "无法删除持久化 GA 缓存",
144
- "reveal_keyring_label": "以明文显示密钥环密码 (KNX Secure)",
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
- node.saveExposedGAs = async () => {
90
- const sFile = path.join(node.serverKNX.userDir, 'knxpersistvalues', 'knxpersist' + node.id + '.json')
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
- fs.writeFileSync(sFile, JSON.stringify(node.exposedGAs))
94
- if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.info('knxUltimateAutoResponder: wrote peristent values to the file ' + sFile)
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 ' + sFile + ' ' + err.message)
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 = path.join(node.serverKNX.userDir, 'knxpersistvalues', 'knxpersist' + node.id + '.json')
103
+ const sFile = persistFile
102
104
  try {
103
- node.exposedGAs = JSON.parse(fs.readFileSync(sFile, 'utf8'))
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
- try {
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 fs = require('fs')
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 (!fs.existsSync(node.filePath)) return []
88
+ if (!storage.exists(node.filePath)) return []
86
89
  try {
87
- const fileContent = fs.readFileSync(node.filePath, 'utf8')
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
- fs.writeFileSync(node.filePath, fileContent, { encoding: 'utf8' })
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}`)