node-red-contrib-knx-ultimate 5.0.4 → 5.2.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 +24 -0
- package/nodes/commonFunctions.js +93 -0
- package/nodes/icons/node-matter-icon.svg +12 -0
- package/nodes/knxUltimateMatterBridge.html +342 -0
- package/nodes/knxUltimateMatterBridge.js +313 -0
- package/nodes/knxUltimateMatterDevice.html +632 -0
- package/nodes/knxUltimateMatterDevice.js +308 -0
- package/nodes/locales/de/knxUltimateMatterBridge.html +53 -0
- package/nodes/locales/de/knxUltimateMatterBridge.json +83 -0
- package/nodes/locales/de/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/de/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/de/matter-config.html +25 -0
- package/nodes/locales/de/matter-config.json +26 -0
- package/nodes/locales/de/matterbridge-config.html +34 -0
- package/nodes/locales/de/matterbridge-config.json +20 -0
- package/nodes/locales/en/knxUltimateMatterBridge.html +53 -0
- package/nodes/locales/en/knxUltimateMatterBridge.json +83 -0
- package/nodes/locales/en/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/en/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/en/matter-config.html +25 -0
- package/nodes/locales/en/matter-config.json +26 -0
- package/nodes/locales/en/matterbridge-config.html +34 -0
- package/nodes/locales/en/matterbridge-config.json +20 -0
- package/nodes/locales/es/knxUltimateMatterBridge.html +53 -0
- package/nodes/locales/es/knxUltimateMatterBridge.json +83 -0
- package/nodes/locales/es/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/es/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/es/matter-config.html +25 -0
- package/nodes/locales/es/matter-config.json +26 -0
- package/nodes/locales/es/matterbridge-config.html +34 -0
- package/nodes/locales/es/matterbridge-config.json +20 -0
- package/nodes/locales/fr/knxUltimateMatterBridge.html +53 -0
- package/nodes/locales/fr/knxUltimateMatterBridge.json +83 -0
- package/nodes/locales/fr/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/fr/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/fr/matter-config.html +25 -0
- package/nodes/locales/fr/matter-config.json +26 -0
- package/nodes/locales/fr/matterbridge-config.html +34 -0
- package/nodes/locales/fr/matterbridge-config.json +20 -0
- package/nodes/locales/it/knxUltimateMatterBridge.html +53 -0
- package/nodes/locales/it/knxUltimateMatterBridge.json +83 -0
- package/nodes/locales/it/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/it/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/it/matter-config.html +25 -0
- package/nodes/locales/it/matter-config.json +26 -0
- package/nodes/locales/it/matterbridge-config.html +34 -0
- package/nodes/locales/it/matterbridge-config.json +20 -0
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +53 -0
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +83 -0
- package/nodes/locales/zh-CN/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/zh-CN/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/zh-CN/matter-config.html +25 -0
- package/nodes/locales/zh-CN/matter-config.json +26 -0
- package/nodes/locales/zh-CN/matterbridge-config.html +34 -0
- package/nodes/locales/zh-CN/matterbridge-config.json +20 -0
- package/nodes/matter-config.html +226 -0
- package/nodes/matter-config.js +242 -0
- package/nodes/matterbridge-config.html +121 -0
- package/nodes/matterbridge-config.js +220 -0
- package/nodes/utils/matterBridgeDeviceFactory.mjs +511 -0
- package/nodes/utils/matterBridgeEngine.mjs +290 -0
- package/nodes/utils/matterEngine.mjs +475 -0
- package/nodes/utils/matterKnxConverter.js +204 -0
- package/package.json +8 -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 }
|