node-red-contrib-knx-ultimate 6.0.5 → 6.0.7

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 (61) hide show
  1. package/CHANGELOG.md +8 -2
  2. package/README.md +1 -1
  3. package/nodes/commonFunctions.js +33 -0
  4. package/nodes/icons/node-matter-bridge-icon.svg +6 -0
  5. package/nodes/icons/node-matter-controller-icon.svg +6 -0
  6. package/nodes/knxUltimate-config.js +5 -1
  7. package/nodes/knxUltimateMatterBridge.html +1 -6
  8. package/nodes/knxUltimateMatterControllerDevice.html +156 -16
  9. package/nodes/knxUltimateMatterControllerDevice.js +5 -0
  10. package/nodes/locales/de/knxUltimateMatterBridge.html +2 -0
  11. package/nodes/locales/de/knxUltimateMatterControllerDevice.html +2 -0
  12. package/nodes/locales/de/knxUltimateMatterControllerDevice.json +10 -0
  13. package/nodes/locales/de/matter-config.html +4 -0
  14. package/nodes/locales/de/matter-config.json +7 -2
  15. package/nodes/locales/de/matterbridge-config.html +4 -0
  16. package/nodes/locales/de/matterbridge-config.json +4 -1
  17. package/nodes/locales/en/knxUltimateMatterBridge.html +2 -0
  18. package/nodes/locales/en/knxUltimateMatterControllerDevice.html +2 -0
  19. package/nodes/locales/en/knxUltimateMatterControllerDevice.json +10 -0
  20. package/nodes/locales/en/matter-config.html +4 -0
  21. package/nodes/locales/en/matter-config.json +7 -2
  22. package/nodes/locales/en/matterbridge-config.html +4 -0
  23. package/nodes/locales/en/matterbridge-config.json +4 -1
  24. package/nodes/locales/es/knxUltimateMatterBridge.html +2 -0
  25. package/nodes/locales/es/knxUltimateMatterControllerDevice.html +2 -0
  26. package/nodes/locales/es/knxUltimateMatterControllerDevice.json +10 -0
  27. package/nodes/locales/es/matter-config.html +4 -0
  28. package/nodes/locales/es/matter-config.json +7 -2
  29. package/nodes/locales/es/matterbridge-config.html +4 -0
  30. package/nodes/locales/es/matterbridge-config.json +4 -1
  31. package/nodes/locales/fr/knxUltimateMatterBridge.html +2 -0
  32. package/nodes/locales/fr/knxUltimateMatterControllerDevice.html +2 -0
  33. package/nodes/locales/fr/knxUltimateMatterControllerDevice.json +10 -0
  34. package/nodes/locales/fr/matter-config.html +4 -0
  35. package/nodes/locales/fr/matter-config.json +7 -2
  36. package/nodes/locales/fr/matterbridge-config.html +4 -0
  37. package/nodes/locales/fr/matterbridge-config.json +4 -1
  38. package/nodes/locales/it/knxUltimateMatterBridge.html +2 -0
  39. package/nodes/locales/it/knxUltimateMatterControllerDevice.html +2 -0
  40. package/nodes/locales/it/knxUltimateMatterControllerDevice.json +10 -0
  41. package/nodes/locales/it/matter-config.html +4 -0
  42. package/nodes/locales/it/matter-config.json +7 -2
  43. package/nodes/locales/it/matterbridge-config.html +4 -0
  44. package/nodes/locales/it/matterbridge-config.json +4 -1
  45. package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +2 -0
  46. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.html +2 -0
  47. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.json +10 -0
  48. package/nodes/locales/zh-CN/matter-config.html +4 -0
  49. package/nodes/locales/zh-CN/matter-config.json +7 -2
  50. package/nodes/locales/zh-CN/matterbridge-config.html +4 -0
  51. package/nodes/locales/zh-CN/matterbridge-config.json +4 -1
  52. package/nodes/matter-config.html +59 -18
  53. package/nodes/matter-config.js +33 -0
  54. package/nodes/matterbridge-config.html +73 -3
  55. package/nodes/matterbridge-config.js +38 -0
  56. package/nodes/utils/matterControllerProfiles/doorLock.js +210 -0
  57. package/nodes/utils/matterControllerProfiles/index.js +21 -0
  58. package/nodes/utils/matterControllerProfiles/mappedEndpoint.js +148 -0
  59. package/nodes/utils/matterStorageBackup.js +100 -0
  60. package/package.json +6 -2
  61. package/resources/htmlUtils.js +11 -0
@@ -0,0 +1,148 @@
1
+ 'use strict'
2
+
3
+ const dptlib = require('knxultimate').dptlib
4
+ const { knxToMatter, matterToKnx } = require('../matterKnxConverter')
5
+
6
+ const isValidGroupAddress = (value) => {
7
+ const parts = String(value || '').trim().split('/')
8
+ if (parts.length !== 3 || parts.some((part) => !/^\d+$/.test(part))) return false
9
+ const [main, middle, sub] = parts.map(Number)
10
+ return main >= 0 && main <= 31 && middle >= 0 && middle <= 7 && sub >= 0 && sub <= 255
11
+ }
12
+
13
+ const parseMappings = (value) => {
14
+ try {
15
+ const parsed = Array.isArray(value) ? value : JSON.parse(value || '[]')
16
+ return parsed.filter((mapping) => {
17
+ if (!mapping || !isValidGroupAddress(mapping.ga) || !mapping.target || !mapping.dpt) return false
18
+ try { dptlib.resolve(mapping.dpt); return true } catch (error) { return false }
19
+ })
20
+ } catch (error) {
21
+ return []
22
+ }
23
+ }
24
+
25
+ const setupMappedEndpointProfile = (RED, node, config) => {
26
+ node.name = config.name || node.matterDeviceName || 'Control Matter from KNX'
27
+ node.topic = node.name
28
+ node.mappings = parseMappings(config.matterMappings)
29
+ node.knxUltimateAcceptedGAs = [...new Set(node.mappings.map((mapping) => String(mapping.ga).trim()))]
30
+ node.notifyreadrequest = true
31
+ node.notifyreadrequestalsorespondtobus = 'false'
32
+ node.notifyreadrequestalsorespondtobusdefaultvalueifnotinitialized = ''
33
+ node.notifyresponse = false
34
+ node.notifywrite = true
35
+ node.initialread = true
36
+ node.listenallga = true
37
+ node.outputtype = 'write'
38
+ node.outputRBE = 'false'
39
+ node.inputRBE = 'false'
40
+ node.passthrough = 'no'
41
+ const enablePins = config.enableNodePINS === 'yes'
42
+ let lastInitialReadTs = 0
43
+
44
+ const status = (fill, shape, text) => node.status({ fill, shape, text })
45
+ const manager = () => node.serverMatter?.matterManager
46
+ const sendKnx = (mapping, payload, outputtype = 'write') => {
47
+ if (payload === undefined || !node.serverKNX) return false
48
+ node.serverKNX.sendKNXTelegramToKNXEngine({
49
+ grpaddr: mapping.ga,
50
+ payload,
51
+ dpt: mapping.dpt,
52
+ outputtype,
53
+ nodecallerid: node.id
54
+ })
55
+ return true
56
+ }
57
+ const sendCached = (mapping, outputtype) => {
58
+ const currentManager = manager()
59
+ if (!currentManager) return false
60
+ const value = currentManager.getCachedAttribute(node.matterNodeId, mapping.endpointId, mapping.clusterId, mapping.target)
61
+ return sendKnx(mapping, matterToKnx(mapping.clusterId, mapping.target, value), outputtype)
62
+ }
63
+ const enqueue = (mapping, value) => {
64
+ const currentManager = manager()
65
+ if (!currentManager) throw new Error('Matter controller not ready')
66
+ const action = knxToMatter(mapping, value)
67
+ if (!action) return
68
+ const queued = currentManager.writeMatterQueueAdd({
69
+ nodeId: node.matterNodeId,
70
+ endpointId: mapping.endpointId,
71
+ clusterId: mapping.clusterId,
72
+ kind: action.kind,
73
+ name: action.name,
74
+ args: action.args
75
+ })
76
+ if (queued && typeof queued.catch === 'function') queued.catch((error) => status('red', 'ring', error.message))
77
+ }
78
+
79
+ node.handleSend = (msg) => {
80
+ try {
81
+ if (!msg?.knx || !node.knxUltimateAcceptedGAs.includes(String(msg.knx.destination || '').trim())) return
82
+ const matches = node.mappings.filter((mapping) => mapping.ga === msg.knx.destination)
83
+ if (msg.knx.event === 'GroupValue_Read') {
84
+ matches.filter((mapping) => mapping.direction === 'status').forEach((mapping) => sendCached(mapping, 'response'))
85
+ return
86
+ }
87
+ matches.filter((mapping) => mapping.direction === 'command').forEach((mapping) => {
88
+ const value = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(mapping.dpt))
89
+ enqueue(mapping, value)
90
+ })
91
+ status('green', 'dot', 'KNX→Matter')
92
+ } catch (error) {
93
+ RED.log.error(`knxUltimateMatterControllerDevice mapped KNX: ${error.message}`)
94
+ status('red', 'ring', error.message)
95
+ }
96
+ }
97
+ node.handleSendMatter = (event) => {
98
+ try {
99
+ if (String(event?.nodeId) !== String(node.matterNodeId) || Number(event?.endpointId) !== Number(node.matterEndpointId)) return
100
+ node.mappings.filter((mapping) => mapping.direction === 'status' && Number(mapping.clusterId) === Number(event.clusterId) && mapping.target === event.attributeName).forEach((mapping) => {
101
+ sendKnx(mapping, matterToKnx(event.clusterId, event.attributeName, event.value))
102
+ })
103
+ if (enablePins) node.send({ topic: `${event.clusterId}.${event.attributeName}`, payload: event.value, matter: event })
104
+ status('blue', 'dot', `Matter→KNX: ${event.attributeName}`)
105
+ } catch (error) {
106
+ RED.log.error(`knxUltimateMatterControllerDevice mapped Matter: ${error.message}`)
107
+ status('red', 'ring', error.message)
108
+ }
109
+ }
110
+ node.handleMatterClusterEvent = (event) => {
111
+ if (enablePins && String(event?.nodeId) === String(node.matterNodeId) && Number(event?.endpointId) === Number(node.matterEndpointId)) {
112
+ node.send({ topic: `${event.clusterId}.${event.eventName}`, payload: event.events, matter: event })
113
+ }
114
+ }
115
+ node.handleMatterNodeInitialized = () => {
116
+ if (config.readStatusAtStartup === 'no' || Date.now() - lastInitialReadTs < 5000) return
117
+ let sent = 0
118
+ node.mappings.filter((mapping) => mapping.direction === 'status').forEach((mapping) => { if (sendCached(mapping, 'write')) sent += 1 })
119
+ if (sent > 0) lastInitialReadTs = Date.now()
120
+ }
121
+ node.setNodeStatusMatter = (value) => { if (value?.text) status(value.fill || 'grey', value.shape || 'ring', value.text) }
122
+
123
+ if (node.serverKNX) { node.serverKNX.removeClient(node); node.serverKNX.addClient(node) } else status('yellow', 'ring', 'No KNX gateway selected')
124
+ if (node.serverMatter) { node.serverMatter.removeClient(node); node.serverMatter.addClient(node) }
125
+ node.on('input', (msg, send, done) => {
126
+ try {
127
+ const payload = msg.payload || {}
128
+ const mapping = {
129
+ endpointId: payload.endpointId ?? node.matterEndpointId,
130
+ clusterId: payload.clusterId,
131
+ targetKind: payload.command !== undefined ? 'command' : 'attribute',
132
+ target: payload.command ?? payload.attribute
133
+ }
134
+ if (!mapping.target || mapping.clusterId === undefined) throw new Error('Matter input requires clusterId and command or attribute')
135
+ enqueue(mapping, payload.args ?? payload.value)
136
+ if (done) done()
137
+ } catch (error) {
138
+ if (done) done(error); else node.error(error, msg)
139
+ }
140
+ })
141
+ node.on('close', (done) => {
142
+ try { if (node.serverKNX) node.serverKNX.removeClient(node) } catch (error) { /* empty */ }
143
+ try { if (node.serverMatter) node.serverMatter.removeClient(node) } catch (error) { /* empty */ }
144
+ done()
145
+ })
146
+ }
147
+
148
+ module.exports = { isValidGroupAddress, parseMappings, setupMappedEndpointProfile }
@@ -0,0 +1,100 @@
1
+ const fs = require('fs/promises')
2
+ const path = require('path')
3
+
4
+ const BACKUP_FORMAT = 'knx-ultimate-matter-storage'
5
+ const BACKUP_VERSION = 1
6
+ const MAX_BACKUP_BYTES = 64 * 1024 * 1024
7
+
8
+ const instancePath = (storagePath, instanceId) => path.join(storagePath, instanceId)
9
+
10
+ const listFiles = async (root, current = root) => {
11
+ const entries = await fs.readdir(current, { withFileTypes: true })
12
+ const files = []
13
+ for (const entry of entries) {
14
+ if (entry.isSymbolicLink()) throw new Error('Matter storage contains an unsupported symbolic link')
15
+ const absolute = path.join(current, entry.name)
16
+ if (entry.isDirectory()) files.push(...await listFiles(root, absolute))
17
+ else if (entry.isFile()) files.push(path.relative(root, absolute).split(path.sep).join('/'))
18
+ }
19
+ return files.sort()
20
+ }
21
+
22
+ const exportMatterStorage = async ({ storagePath, instanceId, kind, protocolVersion, packageVersion }) => {
23
+ const root = instancePath(storagePath, instanceId)
24
+ let names
25
+ try {
26
+ names = await listFiles(root)
27
+ } catch (error) {
28
+ if (error.code === 'ENOENT') names = []
29
+ else throw error
30
+ }
31
+ const files = []
32
+ let totalBytes = 0
33
+ for (const name of names) {
34
+ const content = await fs.readFile(path.join(root, ...name.split('/')))
35
+ totalBytes += content.length
36
+ if (totalBytes > MAX_BACKUP_BYTES) throw new Error('Matter backup exceeds the 64 MB safety limit')
37
+ files.push({ path: name, content: content.toString('base64') })
38
+ }
39
+ return {
40
+ format: BACKUP_FORMAT,
41
+ version: BACKUP_VERSION,
42
+ kind,
43
+ createdAt: new Date().toISOString(),
44
+ sourceInstanceId: instanceId,
45
+ matterProtocolVersion: protocolVersion,
46
+ matterPackageVersion: packageVersion,
47
+ files
48
+ }
49
+ }
50
+
51
+ const validateRelativePath = (name) => {
52
+ if (typeof name !== 'string' || name === '' || name.includes('\\')) return false
53
+ const normalized = path.posix.normalize(name)
54
+ return normalized === name && !path.posix.isAbsolute(name) && name !== '..' && !name.startsWith('../')
55
+ }
56
+
57
+ const importMatterStorage = async ({ storagePath, instanceId, kind, backup }) => {
58
+ if (!backup || backup.format !== BACKUP_FORMAT || backup.version !== BACKUP_VERSION) throw new Error('Unsupported Matter backup file')
59
+ if (backup.kind !== kind) throw new Error(`This is a Matter ${backup.kind || 'unknown'} backup, not a ${kind} backup`)
60
+ if (!Array.isArray(backup.files)) throw new Error('Invalid Matter backup: files are missing')
61
+
62
+ const decoded = []
63
+ const seen = new Set()
64
+ let totalBytes = 0
65
+ for (const file of backup.files) {
66
+ if (!file || !validateRelativePath(file.path) || seen.has(file.path) || typeof file.content !== 'string') throw new Error('Invalid Matter backup file entry')
67
+ const content = Buffer.from(file.content, 'base64')
68
+ totalBytes += content.length
69
+ if (totalBytes > MAX_BACKUP_BYTES) throw new Error('Matter backup exceeds the 64 MB safety limit')
70
+ seen.add(file.path)
71
+ decoded.push({ path: file.path, content })
72
+ }
73
+
74
+ await fs.mkdir(storagePath, { recursive: true })
75
+ const target = instancePath(storagePath, instanceId)
76
+ const temporary = `${target}.import-${process.pid}-${Date.now()}`
77
+ const previous = `${target}.previous-${process.pid}-${Date.now()}`
78
+ await fs.mkdir(temporary, { recursive: true })
79
+ try {
80
+ for (const file of decoded) {
81
+ const destination = path.join(temporary, ...file.path.split('/'))
82
+ await fs.mkdir(path.dirname(destination), { recursive: true })
83
+ await fs.writeFile(destination, file.content, { mode: 0o600 })
84
+ }
85
+ let hadTarget = true
86
+ try { await fs.rename(target, previous) } catch (error) { if (error.code === 'ENOENT') hadTarget = false; else throw error }
87
+ try {
88
+ await fs.rename(temporary, target)
89
+ } catch (error) {
90
+ if (hadTarget) await fs.rename(previous, target)
91
+ throw error
92
+ }
93
+ if (hadTarget) await fs.rm(previous, { recursive: true, force: true })
94
+ } catch (error) {
95
+ await fs.rm(temporary, { recursive: true, force: true })
96
+ throw error
97
+ }
98
+ }
99
+
100
+ module.exports = { exportMatterStorage, importMatterStorage, BACKUP_FORMAT, BACKUP_VERSION, MAX_BACKUP_BYTES }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "6.0.5",
6
+ "version": "6.0.7",
7
7
  "description": "Control your KNX and KNX Secure intallation via Node-Red! A bunch of KNX nodes, with integrated Philips HUE control, ETS group address importer, KNX AI for diagnosticsand KNX routing between interfaces. Easy to use and highly configurable.",
8
8
  "files": [
9
9
  "nodes/",
@@ -99,7 +99,11 @@
99
99
  "smarthome",
100
100
  "routing"
101
101
  ],
102
- "author": "Massimo Saccani (Supergiovane)",
102
+ "author": {
103
+ "name": "Massimo Saccani",
104
+ "email": "maxsupergiovane@icloud.com",
105
+ "url": "https://youtube.com/@maxsupervibe"
106
+ },
103
107
  "license": "MIT",
104
108
  "scripts": {
105
109
  "build": "npm run knx-ai:build && npm run knx-viewer:build",
@@ -1,6 +1,17 @@
1
1
  /* global window, document, MutationObserver, $, jQuery */
2
2
  /* eslint-disable camelcase */
3
3
 
4
+ // Keep informational tips aligned with the full width of our Node-RED editor forms.
5
+ // Node-RED applies a max-width to .form-tips by default, which otherwise leaves
6
+ // inconsistent empty space in wider edit dialogs.
7
+ ;(function ensureKnxUltimateEditorStyles () {
8
+ if (document.getElementById('knx-ultimate-editor-styles')) return
9
+ const style = document.createElement('style')
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; }'
12
+ document.head.appendChild(style)
13
+ })()
14
+
4
15
  // 31/03/2020 Search Helper
5
16
  function htmlUtilsfullCSVSearch (sourceText, searchString) {
6
17
  let aSearchWords = []