node-red-contrib-knx-ultimate 6.0.1 → 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,11 @@
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
+
9
14
  **Version 6.0.1** - July 2026<br/>
10
15
 
11
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/>
@@ -227,6 +227,13 @@ module.exports = function (RED) {
227
227
  // Matter -> KNX: a controller (Alexa...) sent a command to this device.
228
228
  node.handleMatterCommand = (command) => {
229
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
+ }
230
237
  if (node.enableNodePINS) {
231
238
  try {
232
239
  node.send({
@@ -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({ deviceId: def.id, fn: 'stop', value: true })
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
- endpoint = new Endpoint(typeInfo.device.with(BridgedDeviceBasicInformationServer), initialState)
486
- }
487
-
488
- // Matter -> KNX: react only to real controller interactions (context.offline is true
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.1",
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/",