node-red-contrib-knx-ultimate 5.0.3 → 5.2.1

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 (51) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/nodes/commonFunctions.js +93 -0
  3. package/nodes/icons/node-matter-icon.svg +12 -0
  4. package/nodes/knxUltimateIoTBridge.html +10 -3
  5. package/nodes/knxUltimateMatterBridge.html +429 -0
  6. package/nodes/knxUltimateMatterBridge.js +456 -0
  7. package/nodes/knxUltimateMatterDevice.html +586 -0
  8. package/nodes/knxUltimateMatterDevice.js +308 -0
  9. package/nodes/locales/de/knxUltimateMatterBridge.html +64 -0
  10. package/nodes/locales/de/knxUltimateMatterBridge.json +80 -0
  11. package/nodes/locales/de/knxUltimateMatterDevice.html +56 -0
  12. package/nodes/locales/de/knxUltimateMatterDevice.json +109 -0
  13. package/nodes/locales/de/matter-config.html +25 -0
  14. package/nodes/locales/de/matter-config.json +26 -0
  15. package/nodes/locales/en/knxUltimateMatterBridge.html +64 -0
  16. package/nodes/locales/en/knxUltimateMatterBridge.json +80 -0
  17. package/nodes/locales/en/knxUltimateMatterDevice.html +56 -0
  18. package/nodes/locales/en/knxUltimateMatterDevice.json +109 -0
  19. package/nodes/locales/en/matter-config.html +25 -0
  20. package/nodes/locales/en/matter-config.json +26 -0
  21. package/nodes/locales/es/knxUltimateMatterBridge.html +64 -0
  22. package/nodes/locales/es/knxUltimateMatterBridge.json +80 -0
  23. package/nodes/locales/es/knxUltimateMatterDevice.html +56 -0
  24. package/nodes/locales/es/knxUltimateMatterDevice.json +109 -0
  25. package/nodes/locales/es/matter-config.html +25 -0
  26. package/nodes/locales/es/matter-config.json +26 -0
  27. package/nodes/locales/fr/knxUltimateMatterBridge.html +64 -0
  28. package/nodes/locales/fr/knxUltimateMatterBridge.json +80 -0
  29. package/nodes/locales/fr/knxUltimateMatterDevice.html +56 -0
  30. package/nodes/locales/fr/knxUltimateMatterDevice.json +109 -0
  31. package/nodes/locales/fr/matter-config.html +25 -0
  32. package/nodes/locales/fr/matter-config.json +26 -0
  33. package/nodes/locales/it/knxUltimateMatterBridge.html +64 -0
  34. package/nodes/locales/it/knxUltimateMatterBridge.json +80 -0
  35. package/nodes/locales/it/knxUltimateMatterDevice.html +56 -0
  36. package/nodes/locales/it/knxUltimateMatterDevice.json +109 -0
  37. package/nodes/locales/it/matter-config.html +25 -0
  38. package/nodes/locales/it/matter-config.json +26 -0
  39. package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +64 -0
  40. package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +80 -0
  41. package/nodes/locales/zh-CN/knxUltimateMatterDevice.html +56 -0
  42. package/nodes/locales/zh-CN/knxUltimateMatterDevice.json +109 -0
  43. package/nodes/locales/zh-CN/matter-config.html +25 -0
  44. package/nodes/locales/zh-CN/matter-config.json +26 -0
  45. package/nodes/matter-config.html +226 -0
  46. package/nodes/matter-config.js +242 -0
  47. package/nodes/utils/matterBridgeDeviceFactory.mjs +511 -0
  48. package/nodes/utils/matterBridgeEngine.mjs +290 -0
  49. package/nodes/utils/matterEngine.mjs +475 -0
  50. package/nodes/utils/matterKnxConverter.js +204 -0
  51. package/package.json +7 -2
@@ -0,0 +1,511 @@
1
+ /* eslint-disable max-len */
2
+ // Factory of the bridged Matter endpoints exposed by knxUltimateMatterBridge.
3
+ // Each KNX "virtual device" definition becomes a Matter endpoint under the aggregator.
4
+ import { Endpoint } from '@matter/main'
5
+ import { BridgedDeviceBasicInformationServer } from '@matter/main/behaviors/bridged-device-basic-information'
6
+ import { OnOffLightDevice } from '@matter/main/devices/on-off-light'
7
+ import { DimmableLightDevice } from '@matter/main/devices/dimmable-light'
8
+ import { OnOffPlugInUnitDevice } from '@matter/main/devices/on-off-plug-in-unit'
9
+ import { TemperatureSensorDevice } from '@matter/main/devices/temperature-sensor'
10
+ import { HumiditySensorDevice } from '@matter/main/devices/humidity-sensor'
11
+ import { LightSensorDevice } from '@matter/main/devices/light-sensor'
12
+ import { OccupancySensorDevice } from '@matter/main/devices/occupancy-sensor'
13
+ import { OccupancySensingServer } from '@matter/main/behaviors/occupancy-sensing'
14
+ import { ContactSensorDevice } from '@matter/main/devices/contact-sensor'
15
+ import { WindowCoveringDevice } from '@matter/main/devices/window-covering'
16
+ import { MovementDirection, MovementType, WindowCoveringServer } from '@matter/main/behaviors/window-covering'
17
+ import { ThermostatDevice } from '@matter/main/devices/thermostat'
18
+ import { ThermostatServer } from '@matter/main/behaviors/thermostat'
19
+ import { ExtendedColorLightDevice } from '@matter/main/devices/extended-color-light'
20
+ import { ColorTemperatureLightDevice } from '@matter/main/devices/color-temperature-light'
21
+ import { ColorControlServer } from '@matter/main/behaviors/color-control'
22
+ import { SmokeCoAlarmDevice } from '@matter/main/devices/smoke-co-alarm'
23
+ import { SmokeCoAlarmServer } from '@matter/main/behaviors/smoke-co-alarm'
24
+ import { WaterLeakDetectorDevice } from '@matter/main/devices/water-leak-detector'
25
+ import { AirQualitySensorDevice } from '@matter/main/devices/air-quality-sensor'
26
+ import { AirQualityServer } from '@matter/main/behaviors/air-quality'
27
+ import { CarbonDioxideConcentrationMeasurementServer } from '@matter/main/behaviors/carbon-dioxide-concentration-measurement'
28
+ import { FanDevice } from '@matter/main/devices/fan'
29
+ import { RoboticVacuumCleanerDevice } from '@matter/main/devices/robotic-vacuum-cleaner'
30
+ import { RvcRunModeServer } from '@matter/main/behaviors/rvc-run-mode'
31
+ import { RvcOperationalStateServer } from '@matter/main/behaviors/rvc-operational-state'
32
+ import { ModeUtils } from '@matter/main/behaviors/mode-base'
33
+ import hueColorConverter from './colorManipulators/hueColorConverter.js'
34
+
35
+ const { ColorConverter } = hueColorConverter
36
+ const clamp = (v, min, max) => Math.min(max, Math.max(min, v))
37
+ const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true' || v === 'on'
38
+
39
+ // Catalog of the supported virtual device types.
40
+ // - functions: the abstract functions the KNX node can feed/receive ('onoff', 'level', 'temperature', ...)
41
+ // - commandFunctions: the subset that flows Matter -> KNX (commands from Alexa & Co.)
42
+ const BRIDGE_TYPES = {
43
+ onofflight: { device: OnOffLightDevice, functions: ['onoff'], commandFunctions: ['onoff'] },
44
+ onoffplug: { device: OnOffPlugInUnitDevice, functions: ['onoff'], commandFunctions: ['onoff'] },
45
+ dimmablelight: { device: DimmableLightDevice, functions: ['onoff', 'level'], commandFunctions: ['onoff', 'level'] },
46
+ temperaturesensor: { device: TemperatureSensorDevice, functions: ['temperature'], commandFunctions: [] },
47
+ humiditysensor: { device: HumiditySensorDevice, functions: ['humidity'], commandFunctions: [] },
48
+ lightsensor: { device: LightSensorDevice, functions: ['illuminance'], commandFunctions: [] },
49
+ occupancysensor: { device: OccupancySensorDevice, functions: ['occupancy'], commandFunctions: [] },
50
+ contactsensor: { device: ContactSensorDevice, functions: ['contact'], commandFunctions: [] },
51
+ // Custom construction (dedicated server behaviors), handled in createBridgedEndpoint
52
+ windowcovering: { device: null, functions: ['position'], commandFunctions: [] },
53
+ thermostat: { device: null, functions: ['currenttemp', 'setpoint'], commandFunctions: [] },
54
+ rgblight: { device: null, functions: ['onoff', 'level', 'rgb'], commandFunctions: ['onoff', 'level'] },
55
+ colortemperaturelight: { device: null, functions: ['onoff', 'level', 'colortemp'], commandFunctions: ['onoff', 'level'] },
56
+ smokecoalarm: { device: null, functions: ['smoke', 'co'], commandFunctions: [] },
57
+ waterleakdetector: { device: WaterLeakDetectorDevice, functions: ['leak'], commandFunctions: [] },
58
+ airqualitysensor: { device: null, functions: ['co2'], commandFunctions: [] },
59
+ fan: { device: null, functions: ['fanspeed'], commandFunctions: [] },
60
+ robotvacuum: { device: null, functions: ['rvcstate', 'rvcmode'], commandFunctions: [] } // Flow-only (node PINs)
61
+ }
62
+
63
+ // CO2 ppm -> Matter AirQuality classification (1 Good ... 6 ExtremelyPoor)
64
+ function co2ToAirQuality (ppm) {
65
+ if (ppm < 800) return 1
66
+ if (ppm < 1000) return 2
67
+ if (ppm < 1400) return 3
68
+ if (ppm < 2000) return 4
69
+ if (ppm < 3000) return 5
70
+ return 6
71
+ }
72
+
73
+ // RVC operational states (Matter enum values, incl. the RVC specific ones)
74
+ const RVC_OP_STATES = { stopped: 0, running: 1, paused: 2, error: 3, seekingcharger: 0x40, charging: 0x41, docked: 0x42 }
75
+
76
+ // KNX payload (decoded DPT value) -> Matter attribute patch for endpoint.set()
77
+ function knxValueToMatterPatch (def, fn, value) {
78
+ switch (fn) {
79
+ case 'position': {
80
+ // KNX 5.001 position: 0% = open, 100% = closed (invert flag flips it).
81
+ // Matter lift percent100ths: 0 = open, 10000 = closed.
82
+ let percent = Number(value)
83
+ if (Number.isNaN(percent)) return undefined
84
+ if (def.invertPosition === true) percent = 100 - percent
85
+ return { windowCovering: { currentPositionLiftPercent100ths: clamp(Math.round(percent * 100), 0, 10000) } }
86
+ }
87
+ case 'rgb': {
88
+ // KNX DPT 232.600 payload: { red, green, blue } 0-255 -> Matter hue/saturation (0-254)
89
+ if (value === null || typeof value !== 'object') return undefined
90
+ const red = Number(value.red); const green = Number(value.green); const blue = Number(value.blue)
91
+ if (Number.isNaN(red) || Number.isNaN(green) || Number.isNaN(blue)) return undefined
92
+ const hsv = ColorConverter.rgbToHsv(clamp(red, 0, 255), clamp(green, 0, 255), clamp(blue, 0, 255)) // h,s,v in percent 0-100
93
+ return {
94
+ colorControl: {
95
+ currentHue: clamp(Math.round(hsv.h * 254 / 100), 0, 254),
96
+ currentSaturation: clamp(Math.round(hsv.s * 254 / 100), 0, 254)
97
+ }
98
+ }
99
+ }
100
+ case 'colortemp': {
101
+ // KNX DPT 7.600 carries Kelvin -> Matter mireds (accept mireds too for values < 1000)
102
+ const numeric = Number(value)
103
+ if (Number.isNaN(numeric) || numeric <= 0) return undefined
104
+ const mireds = numeric >= 1000 ? Math.round(1000000 / numeric) : Math.round(numeric)
105
+ return { colorControl: { colorTemperatureMireds: clamp(mireds, 153, 500) } }
106
+ }
107
+ case 'currenttemp': {
108
+ const t = Number(value)
109
+ if (Number.isNaN(t)) return undefined
110
+ return { thermostat: { localTemperature: Math.round(t * 100) } }
111
+ }
112
+ case 'setpoint': {
113
+ const t = Number(value)
114
+ if (Number.isNaN(t)) return undefined
115
+ return { thermostat: { occupiedHeatingSetpoint: clamp(Math.round(t * 100), 700, 3000) } }
116
+ }
117
+ case 'onoff':
118
+ return { onOff: { onOff: truthy(value) } }
119
+ case 'level': {
120
+ const percent = Number(value)
121
+ if (Number.isNaN(percent)) return undefined
122
+ // KNX 0-100% -> Matter level 1-254 (0 is not a valid "on" level for lighting)
123
+ const level = clamp(Math.round(percent * 254 / 100), 1, 254)
124
+ return { levelControl: { currentLevel: level } }
125
+ }
126
+ case 'temperature': {
127
+ const t = Number(value)
128
+ if (Number.isNaN(t)) return undefined
129
+ return { temperatureMeasurement: { measuredValue: Math.round(t * 100) } } // °C -> centi-°C
130
+ }
131
+ case 'humidity': {
132
+ const h = Number(value)
133
+ if (Number.isNaN(h)) return undefined
134
+ return { relativeHumidityMeasurement: { measuredValue: clamp(Math.round(h * 100), 0, 10000) } }
135
+ }
136
+ case 'illuminance': {
137
+ const lux = Number(value)
138
+ if (Number.isNaN(lux)) return undefined
139
+ // Matter: measuredValue = 10000 * log10(lux) + 1 (0 = "too low to measure")
140
+ const measured = lux <= 0 ? 0 : clamp(Math.round(10000 * Math.log10(lux) + 1), 0, 65534)
141
+ return { illuminanceMeasurement: { measuredValue: measured } }
142
+ }
143
+ case 'occupancy':
144
+ return { occupancySensing: { occupancy: { occupied: truthy(value) } } }
145
+ case 'contact':
146
+ return { booleanState: { stateValue: truthy(value) } }
147
+ case 'leak':
148
+ return { booleanState: { stateValue: truthy(value) } } // true = leak detected
149
+ case 'smoke': {
150
+ const alarm = truthy(value)
151
+ return { smokeCoAlarm: { smokeState: alarm ? 2 : 0, expressedState: alarm ? 1 : 0 } } // 2 = critical
152
+ }
153
+ case 'co': {
154
+ const alarm = truthy(value)
155
+ return { smokeCoAlarm: { coState: alarm ? 2 : 0, expressedState: alarm ? 2 : 0 } }
156
+ }
157
+ case 'co2': {
158
+ const ppm = Number(value)
159
+ if (Number.isNaN(ppm) || ppm < 0) return undefined
160
+ return {
161
+ carbonDioxideConcentrationMeasurement: { measuredValue: Math.round(ppm) },
162
+ airQuality: { airQuality: co2ToAirQuality(ppm) }
163
+ }
164
+ }
165
+ case 'fanspeed': {
166
+ const percent = Number(value)
167
+ if (Number.isNaN(percent)) return undefined
168
+ const p = clamp(Math.round(percent), 0, 100)
169
+ return { fanControl: { percentCurrent: p, percentSetting: p, fanMode: p > 0 ? 3 : 0 } } // 3 = High, 0 = Off
170
+ }
171
+ case 'rvcstate': {
172
+ // 'docked' | 'charging' | 'running' | 'paused' | 'stopped' | 'seekingcharger' | 'error' (or the numeric Matter value)
173
+ const key = value !== null && value !== undefined ? value.toString().toLowerCase().trim() : ''
174
+ const state = RVC_OP_STATES[key] !== undefined ? RVC_OP_STATES[key] : Number(value)
175
+ if (Number.isNaN(state)) return undefined
176
+ return { rvcOperationalState: { operationalState: state } }
177
+ }
178
+ case 'rvcmode': {
179
+ const key = value !== null && value !== undefined ? value.toString().toLowerCase().trim() : ''
180
+ if (key !== 'idle' && key !== 'cleaning') return undefined
181
+ return { rvcRunMode: { currentMode: key === 'cleaning' ? 1 : 0 } }
182
+ }
183
+ default:
184
+ return undefined
185
+ }
186
+ }
187
+
188
+ // Wires the ColorControl events of an RGB light and coalesces them into a single
189
+ // KNX RGB command. Controllers set the color as hue+saturation OR as X+Y (two separate
190
+ // attribute reports each): a short debounce merges them before computing the RGB value.
191
+ function attachColorTracker (endpoint, def, onCommand) {
192
+ const tracker = { hue: 0, sat: 0, x: undefined, y: undefined, mode: 0, level: 254, timer: null }
193
+
194
+ const flush = () => {
195
+ tracker.timer = null
196
+ try {
197
+ const v01 = clamp((tracker.level === null || tracker.level === undefined ? 254 : Number(tracker.level)) / 254, 0, 1)
198
+ let rgb
199
+ if (tracker.mode === 1 && tracker.x !== undefined && tracker.y !== undefined) {
200
+ // XY color mode (Matter x/y are 0..65536 for 0..1)
201
+ rgb = ColorConverter.xyBriToRgb(tracker.x / 65536, tracker.y / 65536, Math.max(1, Math.round(v01 * 100)))
202
+ } else {
203
+ // Hue/Saturation mode (Matter 0..254 -> 0..1)
204
+ rgb = ColorConverter.hsvToRgb(clamp(tracker.hue / 254, 0, 1), clamp(tracker.sat / 254, 0, 1), Math.max(0.01, v01))
205
+ }
206
+ if (!rgb) return
207
+ onCommand({
208
+ deviceId: def.id,
209
+ fn: 'rgb',
210
+ value: { red: clamp(Math.round(rgb.r), 0, 255), green: clamp(Math.round(rgb.g), 0, 255), blue: clamp(Math.round(rgb.b), 0, 255) }
211
+ })
212
+ } catch (error) { /* empty */ }
213
+ }
214
+
215
+ const schedule = () => {
216
+ if (tracker.timer !== null) clearTimeout(tracker.timer)
217
+ tracker.timer = setTimeout(flush, 250)
218
+ }
219
+
220
+ endpoint.events.colorControl.currentHue$Changed.on((value, oldValue, context) => {
221
+ try {
222
+ if (value === null || value === undefined) return
223
+ tracker.hue = Number(value)
224
+ if (context?.offline === true) return
225
+ tracker.mode = 0
226
+ schedule()
227
+ } catch (error) { /* empty */ }
228
+ })
229
+ endpoint.events.colorControl.currentSaturation$Changed.on((value, oldValue, context) => {
230
+ try {
231
+ if (value === null || value === undefined) return
232
+ tracker.sat = Number(value)
233
+ if (context?.offline === true) return
234
+ tracker.mode = 0
235
+ schedule()
236
+ } catch (error) { /* empty */ }
237
+ })
238
+ endpoint.events.colorControl.currentX$Changed.on((value, oldValue, context) => {
239
+ try {
240
+ if (value === null || value === undefined) return
241
+ tracker.x = Number(value)
242
+ if (context?.offline === true) return
243
+ tracker.mode = 1
244
+ schedule()
245
+ } catch (error) { /* empty */ }
246
+ })
247
+ endpoint.events.colorControl.currentY$Changed.on((value, oldValue, context) => {
248
+ try {
249
+ if (value === null || value === undefined) return
250
+ tracker.y = Number(value)
251
+ if (context?.offline === true) return
252
+ tracker.mode = 1
253
+ schedule()
254
+ } catch (error) { /* empty */ }
255
+ })
256
+ // Track the brightness (for the RGB computation only: the level command has its own GA)
257
+ endpoint.events.levelControl.currentLevel$Changed.on((value) => {
258
+ try {
259
+ if (value !== null && value !== undefined) tracker.level = Number(value)
260
+ } catch (error) { /* empty */ }
261
+ })
262
+ }
263
+
264
+ /**
265
+ * Creates the bridged Matter endpoint for a virtual device definition.
266
+ * @param {object} def - { id, type, name }
267
+ * @param {string} serialPrefix - stable prefix for serial numbers
268
+ * @param {function} onCommand - callback({ deviceId, fn, value }) for commands coming from Matter controllers.
269
+ * 'value' is already KNX friendly (boolean for onoff, 0-100 for level).
270
+ * @returns {Endpoint}
271
+ */
272
+ function createBridgedEndpoint (def, serialPrefix, onCommand) {
273
+ const typeInfo = BRIDGE_TYPES[def.type]
274
+ if (typeInfo === undefined) throw new Error(`Unsupported Matter bridge device type: ${def.type}`)
275
+ const name = (def.name || def.type).toString().slice(0, 32)
276
+
277
+ const initialState = {
278
+ id: `knx-${def.id}`.slice(0, 32),
279
+ bridgedDeviceBasicInformation: {
280
+ nodeLabel: name, // This is the name Alexa/Google/Apple show and use
281
+ productName: name,
282
+ productLabel: name,
283
+ serialNumber: `${serialPrefix}-${def.id}`.slice(0, 32),
284
+ reachable: true
285
+ }
286
+ }
287
+ // Non nullable attributes need a sane initial value
288
+ if (def.type === 'contactsensor') initialState.booleanState = { stateValue: false }
289
+ if (def.type === 'occupancysensor') initialState.occupancySensing = { occupancy: { occupied: false } }
290
+
291
+ let endpoint
292
+
293
+ if (def.type === 'windowcovering') {
294
+ // The WindowCoveringServer requires the movement logic: we forward it to the KNX bus.
295
+ // Position feedback comes back from the KNX status GA, so we do NOT call the default
296
+ // implementation (which would fake the movement by jumping to the target).
297
+ const invert = def.invertPosition === true
298
+ class KnxCoverServer extends WindowCoveringServer.with('Lift', 'PositionAwareLift') {
299
+ async handleMovement (type, reversed, direction, targetPercent100ths) {
300
+ try {
301
+ if (type !== MovementType.Lift) return
302
+ if (direction === MovementDirection.DefinedByPosition && targetPercent100ths !== undefined && targetPercent100ths !== null) {
303
+ let percent = Math.round(Number(targetPercent100ths) / 100)
304
+ if (invert) percent = 100 - percent
305
+ onCommand({ deviceId: def.id, fn: 'position', value: clamp(percent, 0, 100) })
306
+ } else if (direction === MovementDirection.Open || direction === MovementDirection.Close) {
307
+ // KNX DPT 1.008: 0 = up/open, 1 = down/close
308
+ onCommand({ deviceId: def.id, fn: 'updown', value: direction === MovementDirection.Close })
309
+ }
310
+ } catch (error) { /* empty */ }
311
+ }
312
+
313
+ async handleStopMovement () {
314
+ try {
315
+ onCommand({ deviceId: def.id, fn: 'stop', value: true })
316
+ } catch (error) { /* empty */ }
317
+ return super.handleStopMovement()
318
+ }
319
+ }
320
+ endpoint = new Endpoint(WindowCoveringDevice.with(KnxCoverServer, BridgedDeviceBasicInformationServer), initialState)
321
+ return endpoint
322
+ }
323
+
324
+ if (def.type === 'thermostat') {
325
+ initialState.thermostat = {
326
+ localTemperature: null,
327
+ occupiedHeatingSetpoint: 2000, // 20°C
328
+ controlSequenceOfOperation: 2, // HeatingOnly
329
+ systemMode: 4 // Heat
330
+ }
331
+ endpoint = new Endpoint(ThermostatDevice.with(ThermostatServer.with('Heating'), BridgedDeviceBasicInformationServer), initialState)
332
+ // Setpoint changes from a Matter controller (attribute write or setpointRaiseLower command)
333
+ endpoint.events.thermostat.occupiedHeatingSetpoint$Changed.on((value, oldValue, context) => {
334
+ try {
335
+ if (context?.offline === true) return
336
+ if (value === null || value === undefined) return
337
+ onCommand({ deviceId: def.id, fn: 'setpoint', value: Math.round(Number(value)) / 100 }) // centi-°C -> °C
338
+ } catch (error) { /* empty */ }
339
+ })
340
+ return endpoint
341
+ }
342
+
343
+ if (def.type === 'occupancysensor') {
344
+ // The OccupancySensing cluster is not added by default: the sensor type must be chosen (we expose it as PIR)
345
+ endpoint = new Endpoint(OccupancySensorDevice.with(OccupancySensingServer.with('PassiveInfrared'), BridgedDeviceBasicInformationServer), initialState)
346
+ } else if (def.type === 'rgblight') {
347
+ // colorMode/enhancedColorMode and the color attributes are mandatory initial values
348
+ initialState.colorControl = {
349
+ colorMode: 0, // CurrentHueAndCurrentSaturation
350
+ enhancedColorMode: 0,
351
+ currentHue: 0,
352
+ currentSaturation: 0,
353
+ currentX: Math.round(0.31 * 65536),
354
+ currentY: Math.round(0.33 * 65536)
355
+ }
356
+ endpoint = new Endpoint(ExtendedColorLightDevice.with(ColorControlServer.with('HueSaturation', 'Xy'), BridgedDeviceBasicInformationServer), initialState)
357
+ attachColorTracker(endpoint, def, onCommand)
358
+ } else if (def.type === 'colortemperaturelight') {
359
+ initialState.colorControl = {
360
+ colorMode: 2, // ColorTemperatureMireds
361
+ enhancedColorMode: 2,
362
+ colorTempPhysicalMinMireds: 153, // 6500 K
363
+ colorTempPhysicalMaxMireds: 500, // 2000 K
364
+ colorTemperatureMireds: 250,
365
+ startUpColorTemperatureMireds: 250,
366
+ coupleColorTempToLevelMinMireds: 153
367
+ }
368
+ endpoint = new Endpoint(ColorTemperatureLightDevice.with(ColorControlServer.with('ColorTemperature'), BridgedDeviceBasicInformationServer), initialState)
369
+ endpoint.events.colorControl.colorTemperatureMireds$Changed.on((value, oldValue, context) => {
370
+ try {
371
+ if (context?.offline === true) return
372
+ if (value === null || value === undefined || Number(value) <= 0) return
373
+ onCommand({ deviceId: def.id, fn: 'colortemp', value: Math.round(1000000 / Number(value)) }) // mireds -> Kelvin
374
+ } catch (error) { /* empty */ }
375
+ })
376
+ } else if (def.type === 'smokecoalarm') {
377
+ initialState.smokeCoAlarm = {
378
+ expressedState: 0, // Normal
379
+ smokeState: 0,
380
+ coState: 0,
381
+ batteryAlert: 0,
382
+ testInProgress: false,
383
+ hardwareFaultAlert: false,
384
+ endOfServiceAlert: 0
385
+ }
386
+ endpoint = new Endpoint(SmokeCoAlarmDevice.with(SmokeCoAlarmServer.with('SmokeAlarm', 'CoAlarm'), BridgedDeviceBasicInformationServer), initialState)
387
+ } else if (def.type === 'waterleakdetector') {
388
+ initialState.booleanState = { stateValue: false }
389
+ endpoint = new Endpoint(WaterLeakDetectorDevice.with(BridgedDeviceBasicInformationServer), initialState)
390
+ } else if (def.type === 'airqualitysensor') {
391
+ initialState.airQuality = { airQuality: 0 } // Unknown
392
+ initialState.carbonDioxideConcentrationMeasurement = {
393
+ measuredValue: null,
394
+ minMeasuredValue: null,
395
+ maxMeasuredValue: null,
396
+ measurementUnit: 0, // PPM
397
+ measurementMedium: 0 // Air
398
+ }
399
+ endpoint = new Endpoint(AirQualitySensorDevice.with(
400
+ AirQualityServer.with('Fair', 'Moderate', 'VeryPoor', 'ExtremelyPoor'),
401
+ CarbonDioxideConcentrationMeasurementServer.with('NumericMeasurement'),
402
+ BridgedDeviceBasicInformationServer
403
+ ), initialState)
404
+ } else if (def.type === 'fan') {
405
+ initialState.fanControl = {
406
+ fanMode: 0, // Off
407
+ fanModeSequence: 0, // Off/Low/Med/High (Auto requires the AUT feature)
408
+ percentSetting: 0,
409
+ percentCurrent: 0
410
+ }
411
+ endpoint = new Endpoint(FanDevice.with(BridgedDeviceBasicInformationServer), initialState)
412
+ // Speed changes from the controller (percent write or fan mode change)
413
+ endpoint.events.fanControl.percentSetting$Changed.on((value, oldValue, context) => {
414
+ try {
415
+ if (context?.offline === true) return
416
+ if (value === null || value === undefined) return
417
+ onCommand({ deviceId: def.id, fn: 'fanspeed', value: clamp(Math.round(Number(value)), 0, 100) })
418
+ } catch (error) { /* empty */ }
419
+ })
420
+ endpoint.events.fanControl.fanMode$Changed.on((value, oldValue, context) => {
421
+ try {
422
+ if (context?.offline === true) return
423
+ if (Number(value) === 0) onCommand({ deviceId: def.id, fn: 'fanspeed', value: 0 })
424
+ } catch (error) { /* empty */ }
425
+ })
426
+ } else if (def.type === 'robotvacuum') {
427
+ // Flow-only device: run mode changes and operational commands are forwarded to the flow
428
+ // (node PINs); the flow reports the state back through the input PIN.
429
+ class KnxRvcRunModeServer extends RvcRunModeServer {
430
+ changeToMode ({ newMode }) {
431
+ try {
432
+ const result = ModeUtils.assertModeChange(this.state.supportedModes, this.state.currentMode, newMode)
433
+ if (result.status !== 0) return result
434
+ this.state.currentMode = newMode
435
+ return result
436
+ } catch (error) {
437
+ return { status: 2, statusText: error.message } // GenericFailure
438
+ }
439
+ }
440
+ }
441
+ class KnxRvcOperationalStateServer extends RvcOperationalStateServer {
442
+ async goHome () {
443
+ try { onCommand({ deviceId: def.id, fn: 'rvccommand', value: 'gohome' }) } catch (error) { /* empty */ }
444
+ return { commandResponseState: { errorStateId: 0 } }
445
+ }
446
+
447
+ async pause () {
448
+ try { onCommand({ deviceId: def.id, fn: 'rvccommand', value: 'pause' }) } catch (error) { /* empty */ }
449
+ return { commandResponseState: { errorStateId: 0 } }
450
+ }
451
+
452
+ async resume () {
453
+ try { onCommand({ deviceId: def.id, fn: 'rvccommand', value: 'resume' }) } catch (error) { /* empty */ }
454
+ return { commandResponseState: { errorStateId: 0 } }
455
+ }
456
+ }
457
+ initialState.rvcRunMode = {
458
+ supportedModes: [
459
+ { label: 'Idle', mode: 0, modeTags: [{ value: 0x4000 }] }, // RvcRunMode.ModeTag.Idle
460
+ { label: 'Cleaning', mode: 1, modeTags: [{ value: 0x4001 }] } // RvcRunMode.ModeTag.Cleaning
461
+ ],
462
+ currentMode: 0
463
+ }
464
+ initialState.rvcOperationalState = {
465
+ operationalStateList: [
466
+ { operationalStateId: RVC_OP_STATES.stopped },
467
+ { operationalStateId: RVC_OP_STATES.running },
468
+ { operationalStateId: RVC_OP_STATES.paused },
469
+ { operationalStateId: RVC_OP_STATES.error },
470
+ { operationalStateId: RVC_OP_STATES.seekingcharger },
471
+ { operationalStateId: RVC_OP_STATES.charging },
472
+ { operationalStateId: RVC_OP_STATES.docked }
473
+ ],
474
+ operationalState: RVC_OP_STATES.docked
475
+ }
476
+ endpoint = new Endpoint(RoboticVacuumCleanerDevice.with(KnxRvcRunModeServer, KnxRvcOperationalStateServer, BridgedDeviceBasicInformationServer), initialState)
477
+ // Run mode changed by a controller ("Alexa, start cleaning")
478
+ endpoint.events.rvcRunMode.currentMode$Changed.on((value, oldValue, context) => {
479
+ try {
480
+ if (context?.offline === true) return
481
+ onCommand({ deviceId: def.id, fn: 'rvcmode', value: Number(value) === 1 ? 'cleaning' : 'idle' })
482
+ } catch (error) { /* empty */ }
483
+ })
484
+ } 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
+ })
506
+ }
507
+
508
+ return endpoint
509
+ }
510
+
511
+ export { BRIDGE_TYPES, createBridgedEndpoint, knxValueToMatterPatch }