node-red-contrib-knx-ultimate 6.3.13 → 6.3.15

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 (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/examples/KNX AI - Telegrambot Direct Chat.json +0 -1
  3. package/nodes/knxUltimateAI.html +1 -7
  4. package/nodes/knxUltimateAI.js +265 -29
  5. package/nodes/knxUltimateMatterBridge.html +19 -2
  6. package/nodes/knxUltimateMatterBridge.js +18 -3
  7. package/nodes/locales/de/knxUltimateAI.html +6 -6
  8. package/nodes/locales/de/knxUltimateAI.json +1 -3
  9. package/nodes/locales/de/knxUltimateMatterBridge.html +3 -1
  10. package/nodes/locales/de/knxUltimateMatterBridge.json +5 -1
  11. package/nodes/locales/en/knxUltimateAI.html +6 -6
  12. package/nodes/locales/en/knxUltimateAI.json +1 -3
  13. package/nodes/locales/en/knxUltimateMatterBridge.html +3 -1
  14. package/nodes/locales/en/knxUltimateMatterBridge.json +5 -1
  15. package/nodes/locales/es/knxUltimateAI.html +6 -6
  16. package/nodes/locales/es/knxUltimateAI.json +1 -3
  17. package/nodes/locales/es/knxUltimateMatterBridge.html +3 -1
  18. package/nodes/locales/es/knxUltimateMatterBridge.json +5 -1
  19. package/nodes/locales/fr/knxUltimateAI.html +6 -6
  20. package/nodes/locales/fr/knxUltimateAI.json +1 -3
  21. package/nodes/locales/fr/knxUltimateMatterBridge.html +3 -1
  22. package/nodes/locales/fr/knxUltimateMatterBridge.json +5 -1
  23. package/nodes/locales/it/knxUltimateAI.html +6 -6
  24. package/nodes/locales/it/knxUltimateAI.json +1 -3
  25. package/nodes/locales/it/knxUltimateMatterBridge.html +3 -1
  26. package/nodes/locales/it/knxUltimateMatterBridge.json +5 -1
  27. package/nodes/locales/zh-CN/knxUltimateAI.html +6 -6
  28. package/nodes/locales/zh-CN/knxUltimateAI.json +1 -3
  29. package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +3 -1
  30. package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +5 -1
  31. package/nodes/utils/knxAiChatContext.js +277 -0
  32. package/nodes/utils/knxAiHomeMemory.js +2 -2
  33. package/nodes/utils/matterBridgeDeviceFactory.mjs +130 -65
  34. package/package.json +1 -1
  35. package/resources/KNXAIChatAdapterMappings.js +89 -0
@@ -28,6 +28,11 @@ import { AirQualitySensorDevice } from '@matter/main/devices/air-quality-sensor'
28
28
  import { AirQualityServer } from '@matter/main/behaviors/air-quality'
29
29
  import { CarbonDioxideConcentrationMeasurementServer } from '@matter/main/behaviors/carbon-dioxide-concentration-measurement'
30
30
  import { FanDevice } from '@matter/main/devices/fan'
31
+ import { FanControlServer } from '@matter/main/behaviors/fan-control'
32
+ import { RoomAirConditionerDevice } from '@matter/main/devices/room-air-conditioner'
33
+ import { DoorLockDevice } from '@matter/main/devices/door-lock'
34
+ import { DoorLockServer } from '@matter/main/behaviors/door-lock'
35
+ import { DoorLock } from '@matter/main/clusters/door-lock'
31
36
  import { RoboticVacuumCleanerDevice } from '@matter/main/devices/robotic-vacuum-cleaner'
32
37
  import { RvcRunModeServer } from '@matter/main/behaviors/rvc-run-mode'
33
38
  import { RvcOperationalStateServer } from '@matter/main/behaviors/rvc-operational-state'
@@ -53,6 +58,8 @@ const BRIDGE_TYPES = {
53
58
  // Custom construction (dedicated server behaviors), handled in createBridgedEndpoint
54
59
  windowcovering: { device: null, functions: ['position'], commandFunctions: [] },
55
60
  thermostat: { device: null, functions: ['currenttemp', 'setpoint', 'coolingsetpoint'], commandFunctions: [] },
61
+ roomairconditioner: { device: null, functions: ['onoff', 'currenttemp', 'setpoint', 'coolingsetpoint', 'fanspeed'], commandFunctions: [] },
62
+ doorlock: { device: null, functions: ['lock'], commandFunctions: [] },
56
63
  rgblight: { device: null, functions: ['onoff', 'level', 'rgb'], commandFunctions: ['onoff', 'level'] },
57
64
  colortemperaturelight: { device: null, functions: ['onoff', 'level', 'colortemp'], commandFunctions: ['onoff', 'level'] },
58
65
  smokecoalarm: { device: null, functions: ['smoke', 'co'], commandFunctions: [] },
@@ -124,6 +131,8 @@ function knxValueToMatterPatch (def, fn, value) {
124
131
  }
125
132
  case 'onoff':
126
133
  return { onOff: { onOff: truthy(value) } }
134
+ case 'lock':
135
+ return { doorLock: { lockState: truthy(value) ? DoorLock.LockState.Locked : DoorLock.LockState.Unlocked } }
127
136
  case 'level': {
128
137
  const percent = Number(value)
129
138
  if (Number.isNaN(percent)) return undefined
@@ -281,6 +290,76 @@ function createRawColorControlServer (def, onCommand) {
281
290
  return { RawColorControlServer, attachLevelTracker }
282
291
  }
283
292
 
293
+ function configureThermostatState (initialState, def, fallbackMode) {
294
+ let hasHeating = def.hasHeatingSetpoint === true
295
+ let hasCooling = def.hasCoolingSetpoint === true
296
+ // The Thermostat cluster cannot be composed without at least one operating feature.
297
+ // Preserve the historical heating default for the standalone Thermostat; a Room Air
298
+ // Conditioner with no setpoint GA configured defaults to cooling, its primary use case.
299
+ if (!hasHeating && !hasCooling) {
300
+ hasHeating = fallbackMode === 'heating'
301
+ hasCooling = fallbackMode === 'cooling'
302
+ }
303
+ const features = []
304
+ if (hasHeating) features.push('Heating')
305
+ if (hasCooling) features.push('Cooling')
306
+ initialState.thermostat = {
307
+ localTemperature: null,
308
+ // CoolingOnly=0, HeatingOnly=2, CoolingAndHeating=4 (ControlSequenceOfOperation).
309
+ controlSequenceOfOperation: hasHeating && hasCooling ? 4 : (hasCooling ? 0 : 2),
310
+ // Cool=3, Heat=4 (SystemMode). A dual-mode device starts in Heat.
311
+ systemMode: hasCooling && !hasHeating ? 3 : 4
312
+ }
313
+ if (hasHeating) initialState.thermostat.occupiedHeatingSetpoint = 2000 // 20°C
314
+ if (hasCooling) initialState.thermostat.occupiedCoolingSetpoint = 2400 // 24°C
315
+ return { features, hasHeating, hasCooling }
316
+ }
317
+
318
+ function attachThermostatCommandHandlers (endpoint, def, onCommand, capabilities) {
319
+ // Absolute thermostat targets are Matter attribute writes, not commands. The offline
320
+ // context marks KNX/flow state synchronization and prevents a feedback write to KNX.
321
+ if (capabilities.hasHeating) {
322
+ endpoint.events.thermostat.occupiedHeatingSetpoint$Changed.on((value, oldValue, context) => {
323
+ try {
324
+ if (context?.offline === true || value === null || value === undefined) return
325
+ onCommand({ deviceId: def.id, fn: 'setpoint', value: Math.round(Number(value)) / 100 })
326
+ } catch (error) { /* empty */ }
327
+ })
328
+ }
329
+ if (capabilities.hasCooling) {
330
+ endpoint.events.thermostat.occupiedCoolingSetpoint$Changed.on((value, oldValue, context) => {
331
+ try {
332
+ if (context?.offline === true || value === null || value === undefined) return
333
+ onCommand({ deviceId: def.id, fn: 'coolingsetpoint', value: Math.round(Number(value)) / 100 })
334
+ } catch (error) { /* empty */ }
335
+ })
336
+ }
337
+ }
338
+
339
+ function configureFanState (initialState) {
340
+ initialState.fanControl = {
341
+ fanMode: 0, // Off
342
+ fanModeSequence: 0, // Off/Low/Med/High (Auto requires the AUT feature)
343
+ percentSetting: 0,
344
+ percentCurrent: 0
345
+ }
346
+ }
347
+
348
+ function attachFanCommandHandlers (endpoint, def, onCommand) {
349
+ endpoint.events.fanControl.percentSetting$Changed.on((value, oldValue, context) => {
350
+ try {
351
+ if (context?.offline === true || value === null || value === undefined) return
352
+ onCommand({ deviceId: def.id, fn: 'fanspeed', value: clamp(Math.round(Number(value)), 0, 100) })
353
+ } catch (error) { /* empty */ }
354
+ })
355
+ endpoint.events.fanControl.fanMode$Changed.on((value, oldValue, context) => {
356
+ try {
357
+ if (context?.offline === true) return
358
+ if (Number(value) === 0) onCommand({ deviceId: def.id, fn: 'fanspeed', value: 0 })
359
+ } catch (error) { /* empty */ }
360
+ })
361
+ }
362
+
284
363
  /**
285
364
  * Creates the bridged Matter endpoint for a virtual device definition.
286
365
  * @param {object} def - { id, type, name }
@@ -353,7 +432,9 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
353
432
  return false
354
433
  }
355
434
 
356
- const RawOnOffBase = def.type === 'onoffplug' ? OnOffServer : OnOffServer.with('Lighting')
435
+ const RawOnOffBase = def.type === 'onoffplug'
436
+ ? OnOffServer
437
+ : def.type === 'roomairconditioner' ? OnOffServer.with('DeadFrontBehavior') : OnOffServer.with('Lighting')
357
438
  class RawOnOffServer extends RawOnOffBase {
358
439
  async on () {
359
440
  await super.on()
@@ -505,57 +586,58 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
505
586
  // Heating/Cooling capability is auto-detected from which setpoint GA(s) the KNX node
506
587
  // has configured (see knxUltimateMatterBridge.js#getMatterDef). Leaving both flags
507
588
  // unset keeps the original heating-only behavior, so existing flows are unaffected.
508
- const hasCooling = def.hasCoolingSetpoint === true
509
- const hasHeating = def.hasHeatingSetpoint !== false
510
- const features = []
511
- if (hasHeating) features.push('Heating')
512
- if (hasCooling) features.push('Cooling')
513
- if (features.length === 0) features.push('Heating') // never build a thermostat with neither mode
514
-
515
- initialState.thermostat = {
516
- localTemperature: null,
517
- // CoolingOnly=0, HeatingOnly=2, CoolingAndHeating=4 (Matter ControlSequenceOfOperation enum)
518
- controlSequenceOfOperation: hasHeating && hasCooling ? 4 : (hasCooling ? 0 : 2),
519
- // Cool=3, Heat=4 (Matter SystemMode enum). Dual-mode starts in Heat; the controller can
520
- // switch it, same as any physical dual thermostat without an auto/deadband mode.
521
- systemMode: hasCooling && !hasHeating ? 3 : 4
522
- }
523
- if (hasHeating) initialState.thermostat.occupiedHeatingSetpoint = 2000 // 20°C
524
- if (hasCooling) initialState.thermostat.occupiedCoolingSetpoint = 2400 // 24°C
525
-
526
- endpoint = new Endpoint(ThermostatDevice.with(ThermostatServer.with(...features), BridgedDeviceBasicInformationServer), initialState)
527
-
528
- // Setpoint changes from a Matter controller. Unlike OnOff/LevelControl/WindowCovering/
529
- // ColorControl, an absolute setpoint has no cluster COMMAND to intercept - Thermostat
530
- // only exposes SetpointRaiseLower (a *relative* command) and SetActivePresetRequest;
531
- // controllers set an absolute target via a plain attribute write. matter.js only fires
532
- // $Changing/$Changed when the value actually differs (verified in @matter/node's
533
- // Datasource#preCommit), so a duplicate setpoint (same value sent twice) cannot be
534
- // forwarded raw here - there is no lower-level hook to capture it at.
535
- if (hasHeating) {
536
- endpoint.events.thermostat.occupiedHeatingSetpoint$Changed.on((value, oldValue, context) => {
537
- try {
538
- if (context?.offline === true) return
539
- if (value === null || value === undefined) return
540
- onCommand({ deviceId: def.id, fn: 'setpoint', value: Math.round(Number(value)) / 100 }) // centi-°C -> °C
541
- } catch (error) { /* empty */ }
542
- })
543
- }
544
- if (hasCooling) {
545
- endpoint.events.thermostat.occupiedCoolingSetpoint$Changed.on((value, oldValue, context) => {
546
- try {
547
- if (context?.offline === true) return
548
- if (value === null || value === undefined) return
549
- onCommand({ deviceId: def.id, fn: 'coolingsetpoint', value: Math.round(Number(value)) / 100 }) // centi-°C -> °C
550
- } catch (error) { /* empty */ }
551
- })
552
- }
589
+ const thermostat = configureThermostatState(initialState, def, 'heating')
590
+ endpoint = new Endpoint(ThermostatDevice.with(ThermostatServer.with(...thermostat.features), BridgedDeviceBasicInformationServer), initialState)
591
+ attachThermostatCommandHandlers(endpoint, def, onCommand, thermostat)
553
592
  // NOTE: SystemMode (Heat/Cool/Off/Auto) changes from a controller are accepted by the
554
593
  // cluster but not forwarded to KNX yet: there is no single KNX DPT for HVAC mode that
555
594
  // fits every actuator. Track it via the node's optional input PIN if you need it today.
556
595
  return endpoint
557
596
  }
558
597
 
598
+ if (def.type === 'roomairconditioner') {
599
+ // Matter Room Air Conditioner (0x0072) requires OnOff with DeadFrontBehavior and a
600
+ // feature-selected Thermostat server. FanControl is optional in the specification,
601
+ // but included here so the former on/off + thermostat + fan trio becomes one endpoint.
602
+ const thermostat = configureThermostatState(initialState, def, 'cooling')
603
+ configureFanState(initialState)
604
+ endpoint = new Endpoint(RoomAirConditionerDevice.with(
605
+ RawOnOffServer,
606
+ ThermostatServer.with(...thermostat.features),
607
+ FanControlServer,
608
+ BridgedDeviceBasicInformationServer
609
+ ), initialState)
610
+ attachThermostatCommandHandlers(endpoint, def, onCommand, thermostat)
611
+ attachFanCommandHandlers(endpoint, def, onCommand)
612
+ return endpoint
613
+ }
614
+
615
+ if (def.type === 'doorlock') {
616
+ initialState.doorLock = {
617
+ lockState: DoorLock.LockState.Unlocked,
618
+ lockType: DoorLock.LockType.Other,
619
+ actuatorEnabled: true,
620
+ operatingMode: DoorLock.OperatingMode.Normal,
621
+ // matter.js currently defaults these constrained base attributes to 0, which is
622
+ // outside their Matter-defined 1..255 range and prevents endpoint initialization.
623
+ wrongCodeEntryLimit: 3,
624
+ userCodeTemporaryDisableTime: 10
625
+ }
626
+ class KnxDoorLockServer extends DoorLockServer {
627
+ async lockDoor (request) {
628
+ await super.lockDoor(request)
629
+ onCommand({ deviceId: def.id, fn: 'lock', value: true, matterCommand: { cluster: 'DoorLock', command: 'lockDoor' } })
630
+ }
631
+
632
+ async unlockDoor (request) {
633
+ await super.unlockDoor(request)
634
+ onCommand({ deviceId: def.id, fn: 'lock', value: false, matterCommand: { cluster: 'DoorLock', command: 'unlockDoor' } })
635
+ }
636
+ }
637
+ endpoint = new Endpoint(DoorLockDevice.with(KnxDoorLockServer, BridgedDeviceBasicInformationServer), initialState)
638
+ return endpoint
639
+ }
640
+
559
641
  if (def.type === 'occupancysensor') {
560
642
  // The OccupancySensing cluster is not added by default: the sensor type must be chosen (we expose it as PIR)
561
643
  endpoint = new Endpoint(OccupancySensorDevice.with(OccupancySensingServer.with('PassiveInfrared'), BridgedDeviceBasicInformationServer), initialState)
@@ -620,30 +702,13 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
620
702
  BridgedDeviceBasicInformationServer
621
703
  ), initialState)
622
704
  } else if (def.type === 'fan') {
623
- initialState.fanControl = {
624
- fanMode: 0, // Off
625
- fanModeSequence: 0, // Off/Low/Med/High (Auto requires the AUT feature)
626
- percentSetting: 0,
627
- percentCurrent: 0
628
- }
705
+ configureFanState(initialState)
629
706
  endpoint = new Endpoint(FanDevice.with(BridgedDeviceBasicInformationServer), initialState)
630
707
  // Speed changes from the controller (percent write or fan mode change). Like the
631
708
  // Thermostat setpoints above, FanControlServer exposes no command to intercept for
632
709
  // percentSetting (@matter/node's FanControlServer overrides nothing beyond
633
710
  // initialize()) - it is attribute-write only, so this cannot be made "raw" either.
634
- endpoint.events.fanControl.percentSetting$Changed.on((value, oldValue, context) => {
635
- try {
636
- if (context?.offline === true) return
637
- if (value === null || value === undefined) return
638
- onCommand({ deviceId: def.id, fn: 'fanspeed', value: clamp(Math.round(Number(value)), 0, 100) })
639
- } catch (error) { /* empty */ }
640
- })
641
- endpoint.events.fanControl.fanMode$Changed.on((value, oldValue, context) => {
642
- try {
643
- if (context?.offline === true) return
644
- if (Number(value) === 0) onCommand({ deviceId: def.id, fn: 'fanspeed', value: 0 })
645
- } catch (error) { /* empty */ }
646
- })
711
+ attachFanCommandHandlers(endpoint, def, onCommand)
647
712
  } else if (def.type === 'robotvacuum') {
648
713
  // Flow-only device: run mode changes and operational commands are forwarded to the flow
649
714
  // (node PINs); the flow reports the state back through the input PIN.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "6.3.13",
6
+ "version": "6.3.15",
7
7
  "description": "KNX Ultimate is the most advanced KNX integration for Node-RED, providing secure KNX/IP communication, routing, ETS project import, Philips Hue, Matter Controller and Matter Bridge (control matter device via KNX and expose KNX GA via Matter), MQTT, diagnostics with AI, virtual devices, and powerful automation nodes. Build professional, reliable, and scalable smart home and building automation projects with minimal effort.",
8
8
  "files": [
9
9
  "nodes/",
@@ -74,6 +74,95 @@ if (confirmation && confirmation.required === true && Array.isArray(confirmation
74
74
  }
75
75
 
76
76
  msg.payload = telegramPayload;
77
+ return msg;`
78
+ },
79
+ {
80
+ id: 'redbot-telegram',
81
+ title: 'RedBot / node-red-contrib-chatbot (Telegram)',
82
+ inputCode: `// RedBot Telegram Receiver -> KNX AI
83
+ // RedBot normalizes Telegram text and inline-button postbacks in msg.payload.
84
+ const redbot = msg.payload;
85
+ if (!redbot || typeof redbot !== 'object') return;
86
+ if (redbot.transport && redbot.transport !== 'telegram') return;
87
+
88
+ const chatId = redbot.chatId;
89
+ const content = typeof redbot.content === 'string' ? redbot.content.trim() : '';
90
+ if (chatId === undefined || chatId === null || content === '') return;
91
+ if (redbot.type !== 'message') return;
92
+
93
+ msg.sessionId = String(chatId);
94
+ msg.language =
95
+ (msg.originalMessage && msg.originalMessage.from && msg.originalMessage.from.language_code) ||
96
+ redbot.language ||
97
+ msg.language ||
98
+ '';
99
+
100
+ const action = content.toLowerCase();
101
+ if (action === 'confirm' || action === 'cancel') {
102
+ msg.topic = action;
103
+ msg.knxAi = Object.assign({}, msg.knxAi, {
104
+ sessionId: String(chatId),
105
+ confirm: action === 'confirm'
106
+ });
107
+ return msg;
108
+ }
109
+
110
+ msg.topic = 'ask';
111
+ msg.prompt = content;
112
+ return msg;`,
113
+ outputCode: `// KNX AI chat output -> RedBot Telegram Sender
114
+ // Preserves RedBot conversation helpers and emits either a plain message
115
+ // or inline postback buttons for KNX confirmation/cancellation.
116
+ const source = msg.inputMessage && typeof msg.inputMessage === 'object'
117
+ ? msg.inputMessage
118
+ : inputMessage;
119
+ const sourcePayload = source && source.payload && typeof source.payload === 'object'
120
+ ? source.payload
121
+ : {};
122
+ const originalMessage = source && source.originalMessage && typeof source.originalMessage === 'object'
123
+ ? source.originalMessage
124
+ : {};
125
+ const chatId = sourcePayload.chatId !== undefined
126
+ ? sourcePayload.chatId
127
+ : originalMessage.chat && originalMessage.chat.id;
128
+ if (chatId === undefined || chatId === null) return;
129
+
130
+ // RedBot's Sender uses these helpers to resolve the active bot and chat context.
131
+ ['originalMessage', 'chat', 'api', 'client', 'redBot'].forEach(function (key) {
132
+ if (msg[key] === undefined && source && source[key] !== undefined) {
133
+ msg[key] = source[key];
134
+ }
135
+ });
136
+ if (typeof msg.chat !== 'function' || typeof msg.client !== 'function') return;
137
+
138
+ let content = msg.payload;
139
+ if (content && typeof content === 'object') {
140
+ content = content.error || content.message || JSON.stringify(content);
141
+ }
142
+ content = String(content === undefined || content === null ? '' : content);
143
+
144
+ const redbotPayload = {
145
+ transport: sourcePayload.transport || 'telegram',
146
+ chatId: chatId,
147
+ type: 'message',
148
+ inbound: false,
149
+ content: content
150
+ };
151
+ if (sourcePayload.userId !== undefined) redbotPayload.userId = sourcePayload.userId;
152
+
153
+ const confirmation = msg.knxAi && msg.knxAi.confirmationRequest;
154
+ if (confirmation && confirmation.required === true && Array.isArray(confirmation.actions)) {
155
+ redbotPayload.type = 'inline-buttons';
156
+ redbotPayload.buttons = confirmation.actions.map(function (action) {
157
+ return {
158
+ type: 'postback',
159
+ label: action.label,
160
+ value: action.callbackData
161
+ };
162
+ });
163
+ }
164
+
165
+ msg.payload = redbotPayload;
77
166
  return msg;`
78
167
  }
79
168
  ]