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.
- package/CHANGELOG.md +8 -2
- package/README.md +1 -1
- package/nodes/commonFunctions.js +33 -0
- package/nodes/icons/node-matter-bridge-icon.svg +6 -0
- package/nodes/icons/node-matter-controller-icon.svg +6 -0
- package/nodes/knxUltimate-config.js +5 -1
- package/nodes/knxUltimateMatterBridge.html +1 -6
- package/nodes/knxUltimateMatterControllerDevice.html +156 -16
- package/nodes/knxUltimateMatterControllerDevice.js +5 -0
- package/nodes/locales/de/knxUltimateMatterBridge.html +2 -0
- package/nodes/locales/de/knxUltimateMatterControllerDevice.html +2 -0
- package/nodes/locales/de/knxUltimateMatterControllerDevice.json +10 -0
- package/nodes/locales/de/matter-config.html +4 -0
- package/nodes/locales/de/matter-config.json +7 -2
- package/nodes/locales/de/matterbridge-config.html +4 -0
- package/nodes/locales/de/matterbridge-config.json +4 -1
- package/nodes/locales/en/knxUltimateMatterBridge.html +2 -0
- package/nodes/locales/en/knxUltimateMatterControllerDevice.html +2 -0
- package/nodes/locales/en/knxUltimateMatterControllerDevice.json +10 -0
- package/nodes/locales/en/matter-config.html +4 -0
- package/nodes/locales/en/matter-config.json +7 -2
- package/nodes/locales/en/matterbridge-config.html +4 -0
- package/nodes/locales/en/matterbridge-config.json +4 -1
- package/nodes/locales/es/knxUltimateMatterBridge.html +2 -0
- package/nodes/locales/es/knxUltimateMatterControllerDevice.html +2 -0
- package/nodes/locales/es/knxUltimateMatterControllerDevice.json +10 -0
- package/nodes/locales/es/matter-config.html +4 -0
- package/nodes/locales/es/matter-config.json +7 -2
- package/nodes/locales/es/matterbridge-config.html +4 -0
- package/nodes/locales/es/matterbridge-config.json +4 -1
- package/nodes/locales/fr/knxUltimateMatterBridge.html +2 -0
- package/nodes/locales/fr/knxUltimateMatterControllerDevice.html +2 -0
- package/nodes/locales/fr/knxUltimateMatterControllerDevice.json +10 -0
- package/nodes/locales/fr/matter-config.html +4 -0
- package/nodes/locales/fr/matter-config.json +7 -2
- package/nodes/locales/fr/matterbridge-config.html +4 -0
- package/nodes/locales/fr/matterbridge-config.json +4 -1
- package/nodes/locales/it/knxUltimateMatterBridge.html +2 -0
- package/nodes/locales/it/knxUltimateMatterControllerDevice.html +2 -0
- package/nodes/locales/it/knxUltimateMatterControllerDevice.json +10 -0
- package/nodes/locales/it/matter-config.html +4 -0
- package/nodes/locales/it/matter-config.json +7 -2
- package/nodes/locales/it/matterbridge-config.html +4 -0
- package/nodes/locales/it/matterbridge-config.json +4 -1
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +2 -0
- package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.html +2 -0
- package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.json +10 -0
- package/nodes/locales/zh-CN/matter-config.html +4 -0
- package/nodes/locales/zh-CN/matter-config.json +7 -2
- package/nodes/locales/zh-CN/matterbridge-config.html +4 -0
- package/nodes/locales/zh-CN/matterbridge-config.json +4 -1
- package/nodes/matter-config.html +59 -18
- package/nodes/matter-config.js +33 -0
- package/nodes/matterbridge-config.html +73 -3
- package/nodes/matterbridge-config.js +38 -0
- package/nodes/utils/matterControllerProfiles/doorLock.js +210 -0
- package/nodes/utils/matterControllerProfiles/index.js +21 -0
- package/nodes/utils/matterControllerProfiles/mappedEndpoint.js +148 -0
- package/nodes/utils/matterStorageBackup.js +100 -0
- package/package.json +6 -2
- 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.
|
|
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":
|
|
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",
|
package/resources/htmlUtils.js
CHANGED
|
@@ -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 = []
|