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
@@ -1,10 +1,10 @@
1
1
  // Private KNX Utility profile. Independent of the legacy palette entry point.
2
2
  const createProfileTimers = require('./profileTimers')
3
3
  const loggerClass = require('../../sysLogger')
4
+ const { storageOf } = require('../../fileStorage')
4
5
 
5
6
  module.exports = function (RED) {
6
7
  function knxUltimateSceneController (config) {
7
- const fs = require('fs')
8
8
  const path = require('path')
9
9
 
10
10
  RED.nodes.createNode(this, config)
@@ -72,43 +72,9 @@ module.exports = function (RED) {
72
72
  })
73
73
  }
74
74
 
75
- function setupDirectory (aPath) {
76
- try {
77
- return fs.statSync(aPath).isDirectory()
78
- } catch (e) {
79
- // Path does not exist
80
- if (e.code === 'ENOENT') {
81
- // Try and create it
82
- try {
83
- try {
84
- fs.mkdirSync(aPath, { recursive: true })
85
- if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.info('knxUltimate-Scene Controller: created directory path: ' + aPath)
86
- } catch (error) {
87
- if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.error('knxUltimate-Scene Controller: failed to access path:: ' + aPath + ' : ' + error)
88
- return false
89
- }
90
-
91
- return true
92
- } catch (e) {
93
- if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.error('knxUltimate-Scene Controller: failed to create path: ' + aPath + ' : ' + e)
94
- }
95
- }
96
- // Otherwise failure
97
- return false
98
- }
99
- }
100
-
101
- // This stores all scenes values, that are been saved.
102
- try {
103
- setupDirectory(node.userDir)
104
- } catch (error) { }
105
- if (!setupDirectory(node.userDir + '/scenecontroller')) {
106
- if (node.sysLogger !== undefined && node.sysLogger !== null) node.sysLogger.error('knxUltimate-Scene Controller: Unable to set up permanent files directory: ' + node.userDir + '/scenecontroller')
107
- updateStatus({ fill: 'red', shape: 'dot', text: 'Unable to setup permanent files directory' })
108
- } else {
109
-
110
- }
111
-
75
+ // This stores all scenes values, that are been saved. The gateway's
76
+ // File Cache Control decides when they reach the disk.
77
+ const storage = storageOf(node.serverKNX)
112
78
  const scenePath = path.join(node.userDir, 'scenecontroller', 'SceneController_' + node.id)
113
79
  const definitionPath = scenePath + '.config.json'
114
80
  const sceneSchema = rules => rules.map(rule => ({ topic: rule.topic, dpt: rule.dpt || '' }))
@@ -123,20 +89,22 @@ module.exports = function (RED) {
123
89
  // original configured values using the separate, ID-based definition file.
124
90
  try {
125
91
  let oldDefinition
126
- try { oldDefinition = JSON.parse(fs.readFileSync(definitionPath, 'utf8')) } catch (error) {
92
+ try { oldDefinition = JSON.parse(storage.readFile(definitionPath, 'utf8')) } catch (error) {
127
93
  if (error.code !== 'ENOENT') throw error
128
94
  }
129
95
  let oldScene
130
- try { oldScene = JSON.parse(fs.readFileSync(scenePath, 'utf8')) } catch (error) {
96
+ try { oldScene = JSON.parse(storage.readFile(scenePath, 'utf8')) } catch (error) {
131
97
  if (error.code !== 'ENOENT') throw error
132
98
  }
133
99
  const definitionChanged = oldDefinition && JSON.stringify(oldDefinition.rules) !== JSON.stringify(configuredDefinition)
134
100
  const schemaChanged = oldScene && (!Array.isArray(oldScene) || JSON.stringify(sceneSchema(oldScene)) !== JSON.stringify(sceneSchema(configuredDefinition)))
135
101
  if (definitionChanged || schemaChanged) {
136
102
  usePersistedScene = false
137
- if (oldScene) fs.unlinkSync(scenePath)
103
+ if (oldScene) storage.deleteFile(scenePath)
138
104
  }
139
- fs.writeFileSync(definitionPath, JSON.stringify({ version: 1, rules: configuredDefinition }), 'utf8')
105
+ // Rewrite the definition only when it changes.
106
+ const definition = JSON.stringify({ version: 1, rules: configuredDefinition })
107
+ if (!oldDefinition || JSON.stringify(oldDefinition) !== definition) storage.writeFile(definitionPath, definition)
140
108
  } catch (error) {
141
109
  usePersistedScene = false
142
110
  updateStatus({ fill: 'red', shape: 'dot', text: 'Unable to prepare scene storage: ' + error.message })
@@ -244,7 +212,7 @@ module.exports = function (RED) {
244
212
  let oSavedRules = null
245
213
  try {
246
214
  if (usePersistedScene) {
247
- oSavedRules = JSON.parse(fs.readFileSync(scenePath, 'utf8'))
215
+ oSavedRules = JSON.parse(storage.readFile(scenePath, 'utf8'))
248
216
  }
249
217
  } catch (error) { }
250
218
 
@@ -397,7 +365,7 @@ module.exports = function (RED) {
397
365
  }
398
366
  node.setNodeStatus({ fill: 'blue', shape: 'dot', text: 'Saved scene', payload: '', GA: '', dpt: '', devicename: '' })
399
367
  try {
400
- fs.writeFileSync(scenePath, JSON.stringify(node.rules, null, 2), 'utf-8')
368
+ storage.writeFile(scenePath, JSON.stringify(node.rules, null, 2))
401
369
  usePersistedScene = true
402
370
  } catch (error) {
403
371
  node.setNodeStatus({ fill: 'red', shape: 'dot', text: 'Error saving scene. Unable to access filesystem.', payload: '', GA: '', dpt: '', devicename: node.name })
@@ -1,9 +1,6 @@
1
- const fs = require('fs')
2
1
  const path = require('path')
3
2
  const crypto = require('crypto')
4
- const readline = require('readline')
5
- const { once } = require('events')
6
- const { finished } = require('stream/promises')
3
+ const { directStorage } = require('./fileStorage')
7
4
 
8
5
  const HISTORY_TTL_MS = 24 * 60 * 60 * 1000
9
6
  const HOUR_MS = 60 * 60 * 1000
@@ -11,6 +8,7 @@ const MAINTENANCE_MS = 60 * 1000
11
8
  const CHUNK_BYTES = 64 * 1024
12
9
  const MAX_LINE_BYTES = 1024 * 1024
13
10
  const FILE_PATTERN = /^(\d+)\.jsonl$/
11
+ const FILE_OPTIONS = { mode: 0o600, dirMode: 0o700, newlineDelimited: true }
14
12
 
15
13
  // Canonical JSON makes structured values independent of object key order and
16
14
  // treats a Buffer identically before and after its JSON round trip.
@@ -56,21 +54,30 @@ function parseEntry (line) {
56
54
  return null
57
55
  }
58
56
 
59
- async function * forwardLines (filename) {
60
- const stream = fs.createReadStream(filename, { encoding: 'utf8' })
61
- const lines = readline.createInterface({ input: stream, crlfDelay: Infinity })
62
- // readline does not forward input-stream errors to its async iterator.
63
- let streamError
64
- stream.on('error', error => { streamError = error; lines.close() })
57
+ // Files are read through the storage, so data not yet written to disk is included.
58
+ async function * forwardLines (storage, filename) {
59
+ const snapshot = await storage.openSnapshot(filename)
60
+ let position = 0
61
+ let pending = Buffer.alloc(0)
65
62
  try {
66
- for await (const line of lines) {
67
- if (Buffer.byteLength(line) > MAX_LINE_BYTES) throw new Error('KNX Viewer history contains an oversized entry')
68
- if (line) yield line
63
+ while (position < snapshot.size) {
64
+ const chunk = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, snapshot.size - position))
65
+ const { bytesRead } = await snapshot.read(chunk, 0, chunk.length, position)
66
+ if (!bytesRead) break
67
+ position += bytesRead
68
+ const data = Buffer.concat([pending, chunk.subarray(0, bytesRead)])
69
+ let start = 0
70
+ for (let index = data.indexOf(10); index !== -1; index = data.indexOf(10, start)) {
71
+ if (index - start > MAX_LINE_BYTES) throw new Error('KNX Viewer history contains an oversized entry')
72
+ if (index > start) yield data.subarray(start, index).toString('utf8')
73
+ start = index + 1
74
+ }
75
+ pending = data.subarray(start)
76
+ if (pending.length > MAX_LINE_BYTES) throw new Error('KNX Viewer history contains an oversized entry')
69
77
  }
70
- if (streamError) throw streamError
78
+ if (pending.length) yield pending.toString('utf8')
71
79
  } finally {
72
- lines.close()
73
- stream.destroy()
80
+ await snapshot.close()
74
81
  }
75
82
  }
76
83
 
@@ -95,30 +102,26 @@ async function * reverseLines (handle, size) {
95
102
  if (pending.length) yield pending.toString('utf8')
96
103
  }
97
104
 
98
- async function repairIncompleteTail (filename) {
99
- const handle = await fs.promises.open(filename, 'r+')
105
+ async function repairIncompleteTail (storage, filename) {
106
+ const snapshot = await storage.openSnapshot(filename)
107
+ let complete = true
100
108
  try {
101
- let position = (await handle.stat()).size
102
- if (!position) return
103
- while (position > 0) {
104
- const length = Math.min(position, CHUNK_BYTES)
105
- position -= length
106
- const chunk = Buffer.allocUnsafe(length)
107
- const { bytesRead } = await handle.read(chunk, 0, length, position)
108
- const newline = chunk.subarray(0, bytesRead).lastIndexOf(10)
109
- if (newline !== -1) {
110
- await handle.truncate(position + newline + 1)
111
- return
112
- }
109
+ if (snapshot.size) {
110
+ const last = Buffer.alloc(1)
111
+ await snapshot.read(last, 0, 1, snapshot.size - 1)
112
+ complete = last[0] === 10
113
113
  }
114
- await handle.truncate(0)
115
114
  } finally {
116
- await handle.close()
115
+ await snapshot.close()
117
116
  }
117
+ if (complete) return
118
+ const content = storage.readFile(filename)
119
+ storage.writeFile(filename, content.subarray(0, content.lastIndexOf(10) + 1), FILE_OPTIONS)
118
120
  }
119
121
 
120
122
  /** Persistent, paginated state changes for one Viewer and one KNX gateway. */
121
- function createViewerHistory ({ userDir, nodeId, gatewayId, onError, now = Date.now }) {
123
+ // Every file operation goes through the gateway's File Cache Control storage.
124
+ function createViewerHistory ({ userDir, nodeId, gatewayId, onError, now = Date.now, storage = directStorage }) {
122
125
  // Hashing both identifiers prevents path traversal and filesystem name limits.
123
126
  const identity = crypto.createHash('sha256').update(JSON.stringify([String(nodeId), String(gatewayId || '')])).digest('hex')
124
127
  const directory = path.join(userDir, 'knxultimatestorage', 'viewer', identity)
@@ -158,7 +161,7 @@ function createViewerHistory ({ userDir, nodeId, gatewayId, onError, now = Date.
158
161
  }
159
162
 
160
163
  async function files () {
161
- return (await fs.promises.readdir(directory))
164
+ return storage.listDirectory(directory)
162
165
  .filter(name => FILE_PATTERN.test(name))
163
166
  .map(name => ({ name, hour: Number(name.slice(0, -6)), filename: path.join(directory, name) }))
164
167
  .filter(file => Number.isSafeInteger(file.hour))
@@ -174,46 +177,17 @@ function createViewerHistory ({ userDir, nodeId, gatewayId, onError, now = Date.
174
177
  })
175
178
  }
176
179
 
177
- async function compact (file, cutoff) {
178
- const temporary = `${file.filename}.${crypto.randomUUID()}.tmp`
179
- const output = fs.createWriteStream(temporary, { flags: 'wx', mode: 0o600 })
180
- // Observe errors immediately, including ones raised while reading input.
181
- const completion = finished(output)
182
- completion.catch(() => {})
183
- let removed = false
184
- let count = 0
185
- try {
186
- for await (const line of forwardLines(file.filename)) {
187
- const entry = parseEntry(line)
188
- if (!entry || entry.timestampMs < cutoff) { removed = true; continue }
189
- count++
190
- if (!output.write(`${line}\n`)) await once(output, 'drain')
191
- }
192
- output.end()
193
- await completion
194
- if (!removed) await fs.promises.unlink(temporary)
195
- else if (!count) {
196
- await fs.promises.unlink(file.filename)
197
- await fs.promises.unlink(temporary)
198
- hours.delete(file.hour)
199
- } else await fs.promises.rename(temporary, file.filename)
200
- } catch (error) {
201
- output.destroy()
202
- await completion.catch(() => {})
203
- await fs.promises.unlink(temporary).catch(() => {})
204
- throw error
205
- }
206
- }
207
-
208
180
  async function prune (force = false) {
209
181
  const timestamp = clock()
210
182
  if (!force && timestamp - lastPrune < MAINTENANCE_MS) return
211
183
  const cutoff = timestamp - HISTORY_TTL_MS
184
+ // Only whole expired hours are deleted: rewriting a partially expired hour
185
+ // would wear the disk, and queries already skip its expired entries.
212
186
  for (const file of await files()) {
213
187
  if ((file.hour + 1) * HOUR_MS <= cutoff) {
214
- await fs.promises.unlink(file.filename)
188
+ storage.deleteFile(file.filename)
215
189
  hours.delete(file.hour)
216
- } else if (file.hour * HOUR_MS < cutoff) await compact(file, cutoff)
190
+ }
217
191
  }
218
192
  for (const [address, state] of latest) {
219
193
  if (state.timestampMs < cutoff) latest.delete(address)
@@ -223,17 +197,17 @@ function createViewerHistory ({ userDir, nodeId, gatewayId, onError, now = Date.
223
197
 
224
198
  async function initialize () {
225
199
  if (initialized) return
226
- await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 })
227
- for (const name of await fs.promises.readdir(directory)) {
228
- if (/^\d+\.jsonl\.[0-9a-f-]+\.tmp$/.test(name)) await fs.promises.unlink(path.join(directory, name))
200
+ for (const name of storage.listDirectory(directory)) {
201
+ // Left over by the compaction of previous versions.
202
+ if (/^\d+\.jsonl\.[0-9a-f-]+\.tmp$/.test(name)) storage.deleteFile(path.join(directory, name))
229
203
  }
230
- for (const file of await files()) await repairIncompleteTail(file.filename)
204
+ for (const file of await files()) await repairIncompleteTail(storage, file.filename)
231
205
  await prune(true)
232
206
  latest.clear()
233
207
  hours.clear()
234
208
  const cutoff = clock() - HISTORY_TTL_MS
235
209
  for (const file of await files()) {
236
- for await (const line of forwardLines(file.filename)) {
210
+ for await (const line of forwardLines(storage, file.filename)) {
237
211
  const entry = parseEntry(line)
238
212
  if (!entry || entry.timestampMs < cutoff) continue
239
213
  sequence = Math.max(sequence, sequenceOf(entry))
@@ -324,7 +298,7 @@ function createViewerHistory ({ userDir, nodeId, gatewayId, onError, now = Date.
324
298
  if (Buffer.byteLength(line) > MAX_LINE_BYTES) throw new Error('KNX Viewer history entry exceeds 1 MiB')
325
299
  const filename = path.join(directory, `${Math.floor(timestampMs / HOUR_MS)}.jsonl`)
326
300
  try {
327
- await fs.promises.appendFile(filename, line, { encoding: 'utf8', mode: 0o600 })
301
+ storage.appendFile(filename, line, FILE_OPTIONS)
328
302
  } catch (error) {
329
303
  // A failed write can leave half a line; repair it before a later retry.
330
304
  initialized = false
@@ -358,10 +332,8 @@ function createViewerHistory ({ userDir, nodeId, gatewayId, onError, now = Date.
358
332
  const cutoff = timestamp - HISTORY_TTL_MS
359
333
  for (const file of (await files()).reverse()) {
360
334
  if ((file.hour + 1) * HOUR_MS <= cutoff || (cursor && file.hour * HOUR_MS > cursor.timestampMs)) continue
361
- const handle = await fs.promises.open(file.filename, 'r')
362
- const snapshot = { handle, size: 0, ordered: hours.get(file.hour)?.ordered === true }
363
- snapshots.push(snapshot)
364
- snapshot.size = (await handle.stat()).size
335
+ const handle = await storage.openSnapshot(file.filename)
336
+ snapshots.push({ handle, size: handle.size, ordered: hours.get(file.hour)?.ordered === true })
365
337
  }
366
338
  return { timestamp, cutoff }
367
339
  }, null)
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "8.1.0",
6
+ "version": "8.3.0",
7
7
  "description": "KNX integration for Node-RED with KNX/IP and KNX Secure, ETS import, routing, diagnostics, automation utilities, MQTT and Modbus adapters. Optional Hue Ultimate and Matter Ultimate packages connect through the KNX gateway.",
8
8
  "files": [
9
9
  "nodes/",
@@ -19,7 +19,7 @@
19
19
  "scripts/check-legacy-flows.js"
20
20
  ],
21
21
  "dependencies": {
22
- "knxultimate": "6.2.0",
22
+ "knxultimate": "6.3.0",
23
23
  "lodash": "4.18.1",
24
24
  "mqtt": "^5.15.2",
25
25
  "ping": "0.4.4",
@@ -1,4 +1,4 @@
1
- /* global window, document, MutationObserver, $, jQuery */
1
+ /* global window, document, MutationObserver, $, jQuery, RED */
2
2
  /* eslint-disable camelcase */
3
3
 
4
4
  // Keep informational tips aligned with the full width of our Node-RED editor forms.
@@ -8,7 +8,9 @@
8
8
  if (document.getElementById('knx-ultimate-editor-styles')) return
9
9
  const style = document.createElement('style')
10
10
  style.id = 'knx-ultimate-editor-styles'
11
- style.textContent = '.form-tips { width: 100% !important; max-width: none !important; box-sizing: border-box !important; background-color: #e8f5e9 !important; }'
11
+ style.textContent = '.form-tips { width: 100% !important; max-width: none !important; box-sizing: border-box !important; background-color: #e8f5e9 !important; }' +
12
+ // File Cache Control: warning tip shown when the gateway does not write files.
13
+ ' .form-tips.knx-file-cache-disabled-tip { background-color: #fde4e1 !important; border-color: #f2a39b !important; color: #8a1c12 !important; }'
12
14
  document.head.appendChild(style)
13
15
  })()
14
16
 
@@ -103,6 +105,26 @@ function KNX_cleanAutocompleteTerm (term) {
103
105
 
104
106
  window.KNX_cleanAutocompleteTerm = KNX_cleanAutocompleteTerm
105
107
 
108
+ // File Cache Control: show a tip when the selected gateway does not write files to disk.
109
+ // isRelevant() tells whether the node currently uses files; returns the refresh function.
110
+ function KNX_bindFileCacheDisabledTip ($tip, $server, isRelevant) {
111
+ if (!($tip && $tip.length)) return function () { }
112
+ $tip.addClass('knx-file-cache-disabled-tip').text(RED._('node-red-contrib-knx-ultimate/knxUltimate-config:knxUltimate-config.file_cache.disabled_tip'))
113
+ const refresh = function () {
114
+ let disabled = false
115
+ try {
116
+ const gateway = RED.nodes.node($server.val())
117
+ disabled = Boolean(gateway && gateway.fileCacheMode === 'disabled')
118
+ } catch (e) { }
119
+ $tip.toggle(disabled && (typeof isRelevant !== 'function' || isRelevant() === true))
120
+ }
121
+ $server.on('change.knxFileCacheTip', refresh)
122
+ refresh()
123
+ return refresh
124
+ }
125
+
126
+ window.KNX_bindFileCacheDisabledTip = KNX_bindFileCacheDisabledTip
127
+
106
128
  // 2025-09: Make DPT selects searchable via jQuery UI Autocomplete
107
129
  function KNX_makeSelectSearchable ($select) {
108
130
  try {