node-red-contrib-knx-ultimate 6.0.0 → 6.0.2
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
CHANGED
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
# CHANGELOG
|
|
8
8
|
|
|
9
|
+
**Version 6.0.2** - July 2026<br/>
|
|
10
|
+
|
|
11
|
+
- **Expose KNX to Matter (BETA) — covers/shutters with Alexa**: initialize position-aware covers with a valid numeric Matter position instead of an unknown (`null`) value. This helps controllers such as Alexa expose percentage positioning and send `GoToLiftPercentage` instead of degrading to Open/Close only. Added targeted command diagnostics to the Node-RED log and output message for interoperability testing.<br/>
|
|
12
|
+
- **Expose KNX to Matter (BETA) — raw flow output**: On/Off and absolute dimming commands are now captured at the Matter command boundary, so the optional output PIN forwards repeated commands even when the Matter state is already unchanged. Matter validation still runs before KNX routing, and KNX-to-Matter status updates remain loop-protected.<br/>
|
|
13
|
+
|
|
14
|
+
**Version 6.0.1** - July 2026<br/>
|
|
15
|
+
|
|
16
|
+
- **Expose KNX to Matter (BETA) — flow-only covers/shutters**: fixed intermediate position commands reaching the Node-RED output but not being optimistically confirmed to Matter when no KNX command group address was configured. Voice assistants such as Alexa no longer report that the device is not responding after a percentage-position command.<br/>
|
|
17
|
+
|
|
9
18
|
**Version 6.0.0** - July 2026<br/>
|
|
10
19
|
|
|
11
20
|
- **BREAKING CHANGE**: this release intentionally bumps the package to `6.0.0` because the Matter node model changed. The unpublished dedicated Matter controller nodes and the old generic Matter Device node have been replaced by the new single **Control Matter from KNX (BETA)** node, and the KNX-to-Matter bridge/editor behaviour has been reworked. Existing experimental Matter flows created with the previous beta nodes may need to be rebuilt or adjusted.<br/>
|
|
@@ -179,6 +179,22 @@ module.exports = function (RED) {
|
|
|
179
179
|
}
|
|
180
180
|
return false
|
|
181
181
|
}
|
|
182
|
+
|
|
183
|
+
const applyOptimisticCoverPosition = (command) => {
|
|
184
|
+
if (node.deviceType !== 'windowcovering' || node.coverUpdateMode !== 'optimistic' || !node.serverMatterBridge) return
|
|
185
|
+
let optimisticPosition
|
|
186
|
+
if (command.fn === 'position') optimisticPosition = command.value
|
|
187
|
+
if (command.fn === 'updown') optimisticPosition = command.value === true ? 100 : 0
|
|
188
|
+
if (optimisticPosition === undefined) return
|
|
189
|
+
node.serverMatterBridge.setDeviceState(node.matterDeviceId, 'position', optimisticPosition)
|
|
190
|
+
clearCoverStatusTimer()
|
|
191
|
+
if (node.coverStatusTimeoutMs > 0 && hasStatusRouteFor('position')) {
|
|
192
|
+
coverStatusTimer = setTimeout(() => {
|
|
193
|
+
coverStatusTimer = null
|
|
194
|
+
node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: 'Waiting for KNX cover status', payload: optimisticPosition })
|
|
195
|
+
}, node.coverStatusTimeoutMs)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
182
198
|
node.knxUltimateAcceptedGAs = Array.from(statusRoutes.keys())
|
|
183
199
|
|
|
184
200
|
// The device definition consumed by the bridge engine (via the config node).
|
|
@@ -211,6 +227,13 @@ module.exports = function (RED) {
|
|
|
211
227
|
// Matter -> KNX: a controller (Alexa...) sent a command to this device.
|
|
212
228
|
node.handleMatterCommand = (command) => {
|
|
213
229
|
try {
|
|
230
|
+
if (node.deviceType === 'windowcovering' && command.matterDiagnostic && typeof node.log === 'function') {
|
|
231
|
+
node.log(`Matter WindowCovering command: ${JSON.stringify({
|
|
232
|
+
fn: command.fn,
|
|
233
|
+
value: command.value,
|
|
234
|
+
...command.matterDiagnostic
|
|
235
|
+
})}`)
|
|
236
|
+
}
|
|
214
237
|
if (node.enableNodePINS) {
|
|
215
238
|
try {
|
|
216
239
|
node.send({
|
|
@@ -221,6 +244,9 @@ module.exports = function (RED) {
|
|
|
221
244
|
})
|
|
222
245
|
} catch (error) { /* empty */ }
|
|
223
246
|
}
|
|
247
|
+
// Confirm the requested position to Matter even when this is a flow-only
|
|
248
|
+
// device and no KNX command GA is configured.
|
|
249
|
+
applyOptimisticCoverPosition(command)
|
|
224
250
|
const route = commandRoutes.get(command.fn)
|
|
225
251
|
if (route === undefined) {
|
|
226
252
|
if (node.enableNodePINS) return // Flow-only device: the flow already got the command
|
|
@@ -240,21 +266,6 @@ module.exports = function (RED) {
|
|
|
240
266
|
return
|
|
241
267
|
}
|
|
242
268
|
safeSendToKNX({ grpaddr: route.ga, payload: command.value, dpt: route.dpt, outputtype: 'write' }, 'write')
|
|
243
|
-
if (node.deviceType === 'windowcovering' && node.coverUpdateMode === 'optimistic' && node.serverMatterBridge) {
|
|
244
|
-
let optimisticPosition
|
|
245
|
-
if (command.fn === 'position') optimisticPosition = command.value
|
|
246
|
-
if (command.fn === 'updown') optimisticPosition = command.value === true ? 100 : 0
|
|
247
|
-
if (optimisticPosition !== undefined) {
|
|
248
|
-
node.serverMatterBridge.setDeviceState(node.matterDeviceId, 'position', optimisticPosition)
|
|
249
|
-
clearCoverStatusTimer()
|
|
250
|
-
if (node.coverStatusTimeoutMs > 0 && hasStatusRouteFor('position')) {
|
|
251
|
-
coverStatusTimer = setTimeout(() => {
|
|
252
|
-
coverStatusTimer = null
|
|
253
|
-
node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: 'Waiting for KNX cover status', payload: optimisticPosition })
|
|
254
|
-
}, node.coverStatusTimeoutMs)
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
269
|
node.setNodeStatus({ fill: 'green', shape: 'dot', text: `Matter->KNX ${command.fn}`, payload: command.value })
|
|
259
270
|
} catch (error) {
|
|
260
271
|
node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Matter->KNX error ${error.message}` })
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// Each KNX "virtual device" definition becomes a Matter endpoint under the aggregator.
|
|
4
4
|
import { Endpoint } from '@matter/main'
|
|
5
5
|
import { BridgedDeviceBasicInformationServer } from '@matter/main/behaviors/bridged-device-basic-information'
|
|
6
|
+
import { OnOffServer } from '@matter/main/behaviors/on-off'
|
|
7
|
+
import { LevelControlServer } from '@matter/main/behaviors/level-control'
|
|
6
8
|
import { OnOffLightDevice } from '@matter/main/devices/on-off-light'
|
|
7
9
|
import { DimmableLightDevice } from '@matter/main/devices/dimmable-light'
|
|
8
10
|
import { OnOffPlugInUnitDevice } from '@matter/main/devices/on-off-plug-in-unit'
|
|
@@ -287,9 +289,48 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
|
|
|
287
289
|
// Non nullable attributes need a sane initial value
|
|
288
290
|
if (def.type === 'contactsensor') initialState.booleanState = { stateValue: false }
|
|
289
291
|
if (def.type === 'occupancysensor') initialState.occupancySensing = { occupancy: { occupied: false } }
|
|
292
|
+
// Although Matter permits an unknown (null) position, some controllers (including
|
|
293
|
+
// Alexa) then expose only Open/Close instead of percentage positioning. Start with
|
|
294
|
+
// a valid position; KNX or flow feedback replaces it as soon as it is available.
|
|
295
|
+
if (def.type === 'windowcovering') {
|
|
296
|
+
initialState.windowCovering = {
|
|
297
|
+
currentPositionLiftPercent100ths: 0,
|
|
298
|
+
targetPositionLiftPercent100ths: 0
|
|
299
|
+
}
|
|
300
|
+
}
|
|
290
301
|
|
|
291
302
|
let endpoint
|
|
292
303
|
|
|
304
|
+
// Emit controller commands at the command boundary instead of observing attribute
|
|
305
|
+
// changes. This makes the node output truly raw: repeated commands (for example
|
|
306
|
+
// Off while already off) are still forwarded exactly once.
|
|
307
|
+
const RawOnOffBase = def.type === 'onoffplug' ? OnOffServer : OnOffServer.with('Lighting')
|
|
308
|
+
class RawOnOffServer extends RawOnOffBase {
|
|
309
|
+
async on () {
|
|
310
|
+
await super.on()
|
|
311
|
+
onCommand({ deviceId: def.id, fn: 'onoff', value: true, matterCommand: { cluster: 'OnOff', command: 'on' } })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async off () {
|
|
315
|
+
await super.off()
|
|
316
|
+
onCommand({ deviceId: def.id, fn: 'onoff', value: false, matterCommand: { cluster: 'OnOff', command: 'off' } })
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
class RawLevelControlServer extends LevelControlServer.with('Lighting', 'OnOff') {
|
|
321
|
+
async moveToLevel (request) {
|
|
322
|
+
await super.moveToLevel(request)
|
|
323
|
+
const value = clamp(Math.round(Number(request.level) * 100 / 254), 0, 100)
|
|
324
|
+
onCommand({ deviceId: def.id, fn: 'level', value, matterCommand: { cluster: 'LevelControl', command: 'moveToLevel', request } })
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async moveToLevelWithOnOff (request) {
|
|
328
|
+
await super.moveToLevelWithOnOff(request)
|
|
329
|
+
const value = clamp(Math.round(Number(request.level) * 100 / 254), 0, 100)
|
|
330
|
+
onCommand({ deviceId: def.id, fn: 'level', value, matterCommand: { cluster: 'LevelControl', command: 'moveToLevelWithOnOff', request } })
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
293
334
|
if (def.type === 'windowcovering') {
|
|
294
335
|
// The WindowCoveringServer requires the movement logic: we forward it to the KNX bus.
|
|
295
336
|
// Position feedback comes back from the KNX status GA, so we do NOT call the default
|
|
@@ -299,20 +340,35 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
|
|
|
299
340
|
async handleMovement (type, reversed, direction, targetPercent100ths) {
|
|
300
341
|
try {
|
|
301
342
|
if (type !== MovementType.Lift) return
|
|
343
|
+
const matterDiagnostic = {
|
|
344
|
+
handler: 'handleMovement',
|
|
345
|
+
movementType: type,
|
|
346
|
+
reversed: reversed === true,
|
|
347
|
+
direction,
|
|
348
|
+
directionName: direction === MovementDirection.DefinedByPosition
|
|
349
|
+
? 'DefinedByPosition'
|
|
350
|
+
: direction === MovementDirection.Open ? 'Open' : direction === MovementDirection.Close ? 'Close' : 'Unknown',
|
|
351
|
+
targetPercent100ths: targetPercent100ths ?? null
|
|
352
|
+
}
|
|
302
353
|
if (direction === MovementDirection.DefinedByPosition && targetPercent100ths !== undefined && targetPercent100ths !== null) {
|
|
303
354
|
let percent = Math.round(Number(targetPercent100ths) / 100)
|
|
304
355
|
if (invert) percent = 100 - percent
|
|
305
|
-
onCommand({ deviceId: def.id, fn: 'position', value: clamp(percent, 0, 100) })
|
|
356
|
+
onCommand({ deviceId: def.id, fn: 'position', value: clamp(percent, 0, 100), matterDiagnostic })
|
|
306
357
|
} else if (direction === MovementDirection.Open || direction === MovementDirection.Close) {
|
|
307
358
|
// KNX DPT 1.008: 0 = up/open, 1 = down/close
|
|
308
|
-
onCommand({ deviceId: def.id, fn: 'updown', value: direction === MovementDirection.Close })
|
|
359
|
+
onCommand({ deviceId: def.id, fn: 'updown', value: direction === MovementDirection.Close, matterDiagnostic })
|
|
309
360
|
}
|
|
310
361
|
} catch (error) { /* empty */ }
|
|
311
362
|
}
|
|
312
363
|
|
|
313
364
|
async handleStopMovement () {
|
|
314
365
|
try {
|
|
315
|
-
onCommand({
|
|
366
|
+
onCommand({
|
|
367
|
+
deviceId: def.id,
|
|
368
|
+
fn: 'stop',
|
|
369
|
+
value: true,
|
|
370
|
+
matterDiagnostic: { handler: 'handleStopMovement' }
|
|
371
|
+
})
|
|
316
372
|
} catch (error) { /* empty */ }
|
|
317
373
|
return super.handleStopMovement()
|
|
318
374
|
}
|
|
@@ -353,7 +409,7 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
|
|
|
353
409
|
currentX: Math.round(0.31 * 65536),
|
|
354
410
|
currentY: Math.round(0.33 * 65536)
|
|
355
411
|
}
|
|
356
|
-
endpoint = new Endpoint(ExtendedColorLightDevice.with(ColorControlServer.with('HueSaturation', 'Xy'), BridgedDeviceBasicInformationServer), initialState)
|
|
412
|
+
endpoint = new Endpoint(ExtendedColorLightDevice.with(RawOnOffServer, RawLevelControlServer, ColorControlServer.with('HueSaturation', 'Xy'), BridgedDeviceBasicInformationServer), initialState)
|
|
357
413
|
attachColorTracker(endpoint, def, onCommand)
|
|
358
414
|
} else if (def.type === 'colortemperaturelight') {
|
|
359
415
|
initialState.colorControl = {
|
|
@@ -365,7 +421,7 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
|
|
|
365
421
|
startUpColorTemperatureMireds: 250,
|
|
366
422
|
coupleColorTempToLevelMinMireds: 153
|
|
367
423
|
}
|
|
368
|
-
endpoint = new Endpoint(ColorTemperatureLightDevice.with(ColorControlServer.with('ColorTemperature'), BridgedDeviceBasicInformationServer), initialState)
|
|
424
|
+
endpoint = new Endpoint(ColorTemperatureLightDevice.with(RawOnOffServer, RawLevelControlServer, ColorControlServer.with('ColorTemperature'), BridgedDeviceBasicInformationServer), initialState)
|
|
369
425
|
endpoint.events.colorControl.colorTemperatureMireds$Changed.on((value, oldValue, context) => {
|
|
370
426
|
try {
|
|
371
427
|
if (context?.offline === true) return
|
|
@@ -482,27 +538,10 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
|
|
|
482
538
|
} catch (error) { /* empty */ }
|
|
483
539
|
})
|
|
484
540
|
} else {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
// for state changes we apply ourselves via endpoint.set(), which must NOT loop back to KNX).
|
|
490
|
-
if (typeInfo.commandFunctions.includes('onoff')) {
|
|
491
|
-
endpoint.events.onOff.onOff$Changed.on((value, oldValue, context) => {
|
|
492
|
-
try {
|
|
493
|
-
if (context?.offline === true) return
|
|
494
|
-
onCommand({ deviceId: def.id, fn: 'onoff', value: value === true })
|
|
495
|
-
} catch (error) { /* empty */ }
|
|
496
|
-
})
|
|
497
|
-
}
|
|
498
|
-
if (typeInfo.commandFunctions.includes('level')) {
|
|
499
|
-
endpoint.events.levelControl.currentLevel$Changed.on((value, oldValue, context) => {
|
|
500
|
-
try {
|
|
501
|
-
if (context?.offline === true) return
|
|
502
|
-
if (value === null || value === undefined) return
|
|
503
|
-
onCommand({ deviceId: def.id, fn: 'level', value: clamp(Math.round(Number(value) * 100 / 254), 0, 100) })
|
|
504
|
-
} catch (error) { /* empty */ }
|
|
505
|
-
})
|
|
541
|
+
let device = typeInfo.device
|
|
542
|
+
if (typeInfo.commandFunctions.includes('onoff')) device = device.with(RawOnOffServer)
|
|
543
|
+
if (typeInfo.commandFunctions.includes('level')) device = device.with(RawLevelControlServer)
|
|
544
|
+
endpoint = new Endpoint(device.with(BridgedDeviceBasicInformationServer), initialState)
|
|
506
545
|
}
|
|
507
546
|
|
|
508
547
|
return endpoint
|
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.2",
|
|
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/",
|