node-red-contrib-knx-ultimate 6.0.6 → 6.0.8

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.
@@ -0,0 +1,214 @@
1
+ 'use strict'
2
+
3
+ const dptlib = require('knxultimate').dptlib
4
+
5
+ const DOOR_LOCK_CLUSTER_ID = 0x0101
6
+ const LOCK_STATE = {
7
+ NOT_FULLY_LOCKED: 0,
8
+ LOCKED: 1,
9
+ UNLOCKED: 2,
10
+ UNLATCHED: 3
11
+ }
12
+
13
+ const decodeKnxBoolean = (msg, dpt) => {
14
+ if (msg && msg.knx && Buffer.isBuffer(msg.knx.rawValue)) {
15
+ return !!dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(dpt || '1.001'))
16
+ }
17
+ return !!(msg && msg.payload)
18
+ }
19
+
20
+ const lockStateName = (value) => {
21
+ switch (Number(value)) {
22
+ case LOCK_STATE.LOCKED: return 'locked'
23
+ case LOCK_STATE.UNLOCKED: return 'unlocked'
24
+ case LOCK_STATE.NOT_FULLY_LOCKED: return 'notFullyLocked'
25
+ case LOCK_STATE.UNLATCHED: return 'unlatched'
26
+ default: return 'unknown'
27
+ }
28
+ }
29
+
30
+ const lockStateToBoolean = (value) => {
31
+ if (Number(value) === LOCK_STATE.LOCKED) return true
32
+ if (Number(value) === LOCK_STATE.UNLOCKED) return false
33
+ return undefined
34
+ }
35
+
36
+ const setupDoorLockProfile = (RED, node, config) => {
37
+ node.name = config.name || node.matterDeviceName || 'Control Matter door lock from KNX'
38
+ node.topic = node.name
39
+ node.notifyreadrequest = true
40
+ node.notifyreadrequestalsorespondtobus = 'false'
41
+ node.notifyreadrequestalsorespondtobusdefaultvalueifnotinitialized = ''
42
+ node.notifyresponse = false
43
+ node.notifywrite = true
44
+ node.initialread = true
45
+ node.listenallga = true
46
+ node.outputtype = 'write'
47
+ node.outputRBE = 'false'
48
+ node.inputRBE = 'false'
49
+ node.passthrough = 'no'
50
+ node.currentLockState = undefined
51
+ node.knxUltimateAcceptedGAs = [config.GALightSwitch, config.GALightState]
52
+ .map((ga) => String(ga || '').trim())
53
+ .filter((ga) => ga !== '')
54
+
55
+ const setStatus = (fill, shape, text) => node.status({ fill, shape, text })
56
+ // knxUltimate-config invokes this synchronously from addClient(). Profiles return
57
+ // early from the main light constructor, so they must expose the callback before
58
+ // registering with the shared KNX configuration node.
59
+ node.setNodeStatus = ({ fill = 'grey', shape = 'ring', text = '' } = {}) => setStatus(fill, shape, text)
60
+
61
+ const commandArgs = () => {
62
+ const pin = String(node.credentials?.doorLockPin || '')
63
+ return pin === '' ? {} : { pinCode: Buffer.from(pin, 'utf8') }
64
+ }
65
+
66
+ const sendFlow = (source, state, rawState) => {
67
+ if (config.enableNodePINS !== 'yes') return
68
+ node.send({
69
+ topic: node.topic,
70
+ payload: state,
71
+ matter: {
72
+ source,
73
+ nodeId: node.matterNodeId,
74
+ endpointId: node.matterEndpointId,
75
+ clusterId: DOOR_LOCK_CLUSTER_ID,
76
+ lockState: rawState,
77
+ lockStateName: rawState === undefined ? (state ? 'locked' : 'unlocked') : lockStateName(rawState)
78
+ }
79
+ })
80
+ }
81
+
82
+ const writeKnxState = (state, outputtype = 'write') => {
83
+ const ga = String(config.GALightState || '').trim()
84
+ if (ga === '' || !node.serverKNX) return
85
+ node.serverKNX.sendKNXTelegramToKNXEngine({
86
+ grpaddr: ga,
87
+ payload: state,
88
+ dpt: config.dptLightState || '1.001',
89
+ outputtype,
90
+ nodecallerid: node.id
91
+ })
92
+ }
93
+
94
+ const publishMatterState = (rawState, source = 'matter') => {
95
+ node.currentLockState = Number(rawState)
96
+ const state = lockStateToBoolean(rawState)
97
+ const name = lockStateName(rawState)
98
+ if (state !== undefined) writeKnxState(state)
99
+ sendFlow(source, state, rawState)
100
+ setStatus(state === undefined ? 'yellow' : 'blue', state === undefined ? 'ring' : 'dot', `Matter: ${name}`)
101
+ }
102
+
103
+ const queueCommand = (locked, source = 'knx') => {
104
+ const manager = node.serverMatter?.matterManager
105
+ if (!manager) throw new Error('Matter controller not ready')
106
+ const capabilities = (() => {
107
+ try { return JSON.parse(config.matterDeviceCapabilities || '{}') } catch (error) { return {} }
108
+ })()
109
+ if (locked && capabilities.lockDoor === false) throw new Error('The Matter endpoint does not expose lockDoor')
110
+ if (!locked && capabilities.unlockDoor === false) throw new Error('The Matter endpoint does not expose unlockDoor')
111
+ const queued = manager.writeMatterQueueAdd({
112
+ nodeId: node.matterNodeId,
113
+ endpointId: node.matterEndpointId,
114
+ clusterId: DOOR_LOCK_CLUSTER_ID,
115
+ kind: 'command',
116
+ name: locked ? 'lockDoor' : 'unlockDoor',
117
+ args: commandArgs()
118
+ })
119
+ if (queued && typeof queued.catch === 'function') {
120
+ queued.catch((error) => {
121
+ RED.log.error(`knxUltimateMatterControllerDevice DoorLock command: ${error.message}`)
122
+ setStatus('red', 'ring', error.message)
123
+ })
124
+ }
125
+ sendFlow(source, locked)
126
+ setStatus('green', 'dot', `KNX→Matter: ${locked ? 'lock' : 'unlock'}`)
127
+ }
128
+
129
+ node.handleSend = (msg) => {
130
+ try {
131
+ if (!msg?.knx || !node.knxUltimateAcceptedGAs.includes(String(msg.knx.destination || '').trim())) return
132
+ if (msg.knx.event === 'GroupValue_Read') {
133
+ if (String(msg.knx.destination) === String(config.GALightState)) {
134
+ const state = lockStateToBoolean(node.currentLockState)
135
+ if (state !== undefined) writeKnxState(state, 'response')
136
+ }
137
+ return
138
+ }
139
+ if (String(msg.knx.destination) !== String(config.GALightSwitch)) return
140
+ queueCommand(decodeKnxBoolean(msg, config.dptLightSwitch), 'knx')
141
+ } catch (error) {
142
+ RED.log.error(`knxUltimateMatterControllerDevice DoorLock KNX: ${error.message}`)
143
+ setStatus('red', 'ring', error.message)
144
+ }
145
+ }
146
+
147
+ node.handleSendMatter = (event) => {
148
+ try {
149
+ if (String(event?.nodeId) !== String(node.matterNodeId)) return
150
+ if (Number(event?.endpointId) !== Number(node.matterEndpointId)) return
151
+ if (Number(event?.clusterId) !== DOOR_LOCK_CLUSTER_ID || event.attributeName !== 'lockState') return
152
+ publishMatterState(event.value)
153
+ } catch (error) {
154
+ RED.log.error(`knxUltimateMatterControllerDevice DoorLock Matter: ${error.message}`)
155
+ }
156
+ }
157
+ node.handleMatterClusterEvent = () => {}
158
+ node.handleMatterNodeInitialized = () => {
159
+ try {
160
+ const value = node.serverMatter?.matterManager?.getCachedAttribute(
161
+ node.matterNodeId,
162
+ node.matterEndpointId,
163
+ DOOR_LOCK_CLUSTER_ID,
164
+ 'lockState'
165
+ )
166
+ if (value !== undefined && value !== null) publishMatterState(value, 'initial')
167
+ } catch (error) {
168
+ RED.log.error(`knxUltimateMatterControllerDevice DoorLock initial state: ${error.message}`)
169
+ }
170
+ }
171
+ node.setNodeStatusMatter = (status) => {
172
+ if (status && status.text) setStatus(status.fill || 'grey', status.shape || 'ring', status.text)
173
+ }
174
+
175
+ if (node.serverKNX) {
176
+ node.serverKNX.removeClient(node)
177
+ node.serverKNX.addClient(node)
178
+ } else {
179
+ setStatus('yellow', 'ring', 'No KNX gateway selected')
180
+ }
181
+ if (node.serverMatter) {
182
+ node.serverMatter.removeClient(node)
183
+ node.serverMatter.addClient(node)
184
+ }
185
+
186
+ node.on('input', (msg, send, done) => {
187
+ try {
188
+ const value = typeof msg.payload === 'object' && msg.payload !== null && msg.payload.locked !== undefined
189
+ ? msg.payload.locked
190
+ : msg.payload
191
+ if (typeof value !== 'boolean') throw new Error('Door Lock input requires msg.payload boolean or { locked: boolean }')
192
+ queueCommand(value, 'flow')
193
+ if (done) done()
194
+ } catch (error) {
195
+ setStatus('red', 'ring', error.message)
196
+ if (done) done(error)
197
+ else node.error(error, msg)
198
+ }
199
+ })
200
+
201
+ node.on('close', (done) => {
202
+ try { if (node.serverKNX) node.serverKNX.removeClient(node) } catch (error) { /* empty */ }
203
+ try { if (node.serverMatter) node.serverMatter.removeClient(node) } catch (error) { /* empty */ }
204
+ done()
205
+ })
206
+ }
207
+
208
+ module.exports = {
209
+ DOOR_LOCK_CLUSTER_ID,
210
+ LOCK_STATE,
211
+ lockStateName,
212
+ lockStateToBoolean,
213
+ setupDoorLockProfile
214
+ }
@@ -0,0 +1,21 @@
1
+ 'use strict'
2
+
3
+ const { setupDoorLockProfile } = require('./doorLock')
4
+ const { setupMappedEndpointProfile } = require('./mappedEndpoint')
5
+
6
+ // Controller-side Matter device profiles live behind this registry. Keep profile
7
+ // selection capability-driven: the editor records the profile only after inspecting
8
+ // the endpoint's actual device types, clusters, attributes and supported commands.
9
+ const PROFILE_SETUPS = Object.freeze({
10
+ doorLock: setupDoorLockProfile,
11
+ mapped: setupMappedEndpointProfile
12
+ })
13
+
14
+ const setupMatterControllerProfile = (profile, RED, node, config) => {
15
+ const setup = PROFILE_SETUPS[profile]
16
+ if (typeof setup !== 'function') return false
17
+ setup(RED, node, config)
18
+ return true
19
+ }
20
+
21
+ module.exports = { PROFILE_SETUPS, setupMatterControllerProfile }
@@ -0,0 +1,150 @@
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
+ // knxUltimate-config calls setNodeStatus synchronously while adding the client.
46
+ node.setNodeStatus = ({ fill = 'grey', shape = 'ring', text = '' } = {}) => status(fill, shape, text)
47
+ const manager = () => node.serverMatter?.matterManager
48
+ const sendKnx = (mapping, payload, outputtype = 'write') => {
49
+ if (payload === undefined || !node.serverKNX) return false
50
+ node.serverKNX.sendKNXTelegramToKNXEngine({
51
+ grpaddr: mapping.ga,
52
+ payload,
53
+ dpt: mapping.dpt,
54
+ outputtype,
55
+ nodecallerid: node.id
56
+ })
57
+ return true
58
+ }
59
+ const sendCached = (mapping, outputtype) => {
60
+ const currentManager = manager()
61
+ if (!currentManager) return false
62
+ const value = currentManager.getCachedAttribute(node.matterNodeId, mapping.endpointId, mapping.clusterId, mapping.target)
63
+ return sendKnx(mapping, matterToKnx(mapping.clusterId, mapping.target, value), outputtype)
64
+ }
65
+ const enqueue = (mapping, value) => {
66
+ const currentManager = manager()
67
+ if (!currentManager) throw new Error('Matter controller not ready')
68
+ const action = knxToMatter(mapping, value)
69
+ if (!action) return
70
+ const queued = currentManager.writeMatterQueueAdd({
71
+ nodeId: node.matterNodeId,
72
+ endpointId: mapping.endpointId,
73
+ clusterId: mapping.clusterId,
74
+ kind: action.kind,
75
+ name: action.name,
76
+ args: action.args
77
+ })
78
+ if (queued && typeof queued.catch === 'function') queued.catch((error) => status('red', 'ring', error.message))
79
+ }
80
+
81
+ node.handleSend = (msg) => {
82
+ try {
83
+ if (!msg?.knx || !node.knxUltimateAcceptedGAs.includes(String(msg.knx.destination || '').trim())) return
84
+ const matches = node.mappings.filter((mapping) => mapping.ga === msg.knx.destination)
85
+ if (msg.knx.event === 'GroupValue_Read') {
86
+ matches.filter((mapping) => mapping.direction === 'status').forEach((mapping) => sendCached(mapping, 'response'))
87
+ return
88
+ }
89
+ matches.filter((mapping) => mapping.direction === 'command').forEach((mapping) => {
90
+ const value = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(mapping.dpt))
91
+ enqueue(mapping, value)
92
+ })
93
+ status('green', 'dot', 'KNX→Matter')
94
+ } catch (error) {
95
+ RED.log.error(`knxUltimateMatterControllerDevice mapped KNX: ${error.message}`)
96
+ status('red', 'ring', error.message)
97
+ }
98
+ }
99
+ node.handleSendMatter = (event) => {
100
+ try {
101
+ if (String(event?.nodeId) !== String(node.matterNodeId) || Number(event?.endpointId) !== Number(node.matterEndpointId)) return
102
+ node.mappings.filter((mapping) => mapping.direction === 'status' && Number(mapping.clusterId) === Number(event.clusterId) && mapping.target === event.attributeName).forEach((mapping) => {
103
+ sendKnx(mapping, matterToKnx(event.clusterId, event.attributeName, event.value))
104
+ })
105
+ if (enablePins) node.send({ topic: `${event.clusterId}.${event.attributeName}`, payload: event.value, matter: event })
106
+ status('blue', 'dot', `Matter→KNX: ${event.attributeName}`)
107
+ } catch (error) {
108
+ RED.log.error(`knxUltimateMatterControllerDevice mapped Matter: ${error.message}`)
109
+ status('red', 'ring', error.message)
110
+ }
111
+ }
112
+ node.handleMatterClusterEvent = (event) => {
113
+ if (enablePins && String(event?.nodeId) === String(node.matterNodeId) && Number(event?.endpointId) === Number(node.matterEndpointId)) {
114
+ node.send({ topic: `${event.clusterId}.${event.eventName}`, payload: event.events, matter: event })
115
+ }
116
+ }
117
+ node.handleMatterNodeInitialized = () => {
118
+ if (config.readStatusAtStartup === 'no' || Date.now() - lastInitialReadTs < 5000) return
119
+ let sent = 0
120
+ node.mappings.filter((mapping) => mapping.direction === 'status').forEach((mapping) => { if (sendCached(mapping, 'write')) sent += 1 })
121
+ if (sent > 0) lastInitialReadTs = Date.now()
122
+ }
123
+ node.setNodeStatusMatter = (value) => { if (value?.text) status(value.fill || 'grey', value.shape || 'ring', value.text) }
124
+
125
+ if (node.serverKNX) { node.serverKNX.removeClient(node); node.serverKNX.addClient(node) } else status('yellow', 'ring', 'No KNX gateway selected')
126
+ if (node.serverMatter) { node.serverMatter.removeClient(node); node.serverMatter.addClient(node) }
127
+ node.on('input', (msg, send, done) => {
128
+ try {
129
+ const payload = msg.payload || {}
130
+ const mapping = {
131
+ endpointId: payload.endpointId ?? node.matterEndpointId,
132
+ clusterId: payload.clusterId,
133
+ targetKind: payload.command !== undefined ? 'command' : 'attribute',
134
+ target: payload.command ?? payload.attribute
135
+ }
136
+ if (!mapping.target || mapping.clusterId === undefined) throw new Error('Matter input requires clusterId and command or attribute')
137
+ enqueue(mapping, payload.args ?? payload.value)
138
+ if (done) done()
139
+ } catch (error) {
140
+ if (done) done(error); else node.error(error, msg)
141
+ }
142
+ })
143
+ node.on('close', (done) => {
144
+ try { if (node.serverKNX) node.serverKNX.removeClient(node) } catch (error) { /* empty */ }
145
+ try { if (node.serverMatter) node.serverMatter.removeClient(node) } catch (error) { /* empty */ }
146
+ done()
147
+ })
148
+ }
149
+
150
+ module.exports = { isValidGroupAddress, parseMappings, setupMappedEndpointProfile }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "6.0.6",
6
+ "version": "6.0.8",
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",