node-red-contrib-knx-ultimate 6.0.4 → 6.0.5
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 +12 -0
- package/nodes/knxUltimateMatterBridge.html +38 -15
- package/nodes/knxUltimateMatterBridge.js +70 -15
- package/nodes/locales/de/knxUltimateMatterBridge.html +2 -2
- package/nodes/locales/de/knxUltimateMatterBridge.json +13 -10
- package/nodes/locales/en/knxUltimateMatterBridge.html +2 -2
- package/nodes/locales/en/knxUltimateMatterBridge.json +13 -10
- package/nodes/locales/es/knxUltimateMatterBridge.html +2 -2
- package/nodes/locales/es/knxUltimateMatterBridge.json +13 -10
- package/nodes/locales/fr/knxUltimateMatterBridge.html +2 -2
- package/nodes/locales/fr/knxUltimateMatterBridge.json +13 -10
- package/nodes/locales/it/knxUltimateMatterBridge.html +2 -2
- package/nodes/locales/it/knxUltimateMatterBridge.json +13 -10
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +2 -2
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +13 -10
- package/nodes/utils/matterBridgeDeviceFactory.mjs +244 -130
- package/nodes/utils/matterBridgeEngine.mjs +27 -22
- package/nodes/utils/matterEngine.mjs +13 -7
- package/nodes/utils/matterEnvironmentLock.mjs +32 -0
- package/package.json +1 -1
|
@@ -17,24 +17,25 @@
|
|
|
17
17
|
"name_placeholder": "es. Luce cucina",
|
|
18
18
|
"invert": "Inverti posizione %"
|
|
19
19
|
},
|
|
20
|
-
|
|
20
|
+
"advanced": {
|
|
21
21
|
"title": "Compatibilità avanzata",
|
|
22
22
|
"help": " Usa queste opzioni solo quando un controller Matter o un attuatore KNX richiede una gestione speciale.",
|
|
23
|
+
"alexa_dedup_ms": "Finestra anti-duplicati Alexa (ms, 0 = off)",
|
|
23
24
|
"turn_on_behavior": "Comportamento all'accensione",
|
|
24
25
|
"turn_on_ignore_level": "Ignora luminosità inviata subito dopo On",
|
|
25
26
|
"turn_on_forward_all": "Inoltra ogni comando luminosità",
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
"ignore_level_ms": "Ignora luminosità dopo On (ms)",
|
|
28
|
+
"cover_swap_open_close": "Scambia Apri / Chiudi tapparella",
|
|
29
|
+
"cover_slider_debounce": "Debounce slider tapparella (ms, 0 = adattivo)",
|
|
29
30
|
"cover_update_mode": "Aggiornamento stato tapparella",
|
|
30
31
|
"cover_optimistic": "Aggiorna ottimisticamente la posizione Matter",
|
|
31
32
|
"cover_wait_status": "Attendi solo lo stato KNX",
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
"cover_status_timeout": "Timeout stato tapparella (ms)"
|
|
34
|
+
},
|
|
35
|
+
"tabs": {
|
|
36
|
+
"mappings": "Mappature KNX",
|
|
37
|
+
"advanced": "Opzioni avanzate"
|
|
38
|
+
},
|
|
38
39
|
"functions": {
|
|
39
40
|
"fn_onoff_cmd": "GA comando On/Off",
|
|
40
41
|
"fn_onoff_status": "GA stato On/Off",
|
|
@@ -48,6 +49,8 @@
|
|
|
48
49
|
"fn_currenttemp": "GA temperatura attuale",
|
|
49
50
|
"fn_setpoint_cmd": "GA comando setpoint",
|
|
50
51
|
"fn_setpoint_status": "GA stato setpoint",
|
|
52
|
+
"fn_coolingsetpoint_cmd": "GA comando setpoint raffrescamento",
|
|
53
|
+
"fn_coolingsetpoint_status": "GA stato setpoint raffrescamento",
|
|
51
54
|
"fn_rgb_cmd": "GA comando colore RGB (DPT 232.600)",
|
|
52
55
|
"fn_rgb_status": "GA stato colore RGB (DPT 232.600)",
|
|
53
56
|
"fn_ct_cmd": "GA comando temperatura colore (Kelvin)",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
| RGB 彩色灯 | + RGB 颜色命令/状态 GA (DPT 232.600)。Matter 颜色(hue/saturation 或 XY,来自 App 的色轮)与 KNX RGB 三元组相互转换 |
|
|
29
29
|
| 色温灯 | + 色温命令/状态 GA,开尔文 (DPT 7.600) |
|
|
30
30
|
| 窗帘/卷帘 | 上/下 (DPT 1.008)、停止 (DPT 1.017)、位置 % 命令/状态 (DPT 5.001)、可选位置反转 |
|
|
31
|
-
|
|
|
31
|
+
| 温控器(采暖和/或制冷) | 当前温度 GA、设定点命令/状态 GA (DPT 9.001)。如再配置制冷设定点命令/状态 GA,则同时提供 Cool 模式(双设定点) |
|
|
32
32
|
| 风扇/新风 | 速度 % 命令/状态 GA (DPT 5.001) |
|
|
33
33
|
| 传感器(温度、湿度、光照、人体、门磁) | 各一个状态 GA |
|
|
34
34
|
| 烟雾/CO 报警器 | 烟雾报警状态 GA + 可选 CO 报警状态 GA (DPT 1.005):手机上的紧急通知 |
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
|
|
42
42
|
## 高级兼容性
|
|
43
43
|
|
|
44
|
-
这些选项只会在适用于所选类型时显示。调光设备可以忽略某些控制器在 `On`
|
|
44
|
+
这些选项只会在适用于所选类型时显示。调光设备可以忽略某些控制器在 `On` 后立即发送的亮度命令。对于窗帘,**交换打开 / 关闭**会同时反转 KNX 二进制命令和百分比方向。**窗帘滑块防抖**会在写入 KNX 前合并快速的中间目标:`0` 使用自适应窗口(首个命令 400 ms,后续命令 150 ms);`1`–`5000` 使用固定窗口。窗帘还可先乐观更新 Matter 位置,再由 KNX 状态 GA 修正。
|
|
45
45
|
|
|
46
46
|
## 节点引脚
|
|
47
47
|
|
|
@@ -17,24 +17,25 @@
|
|
|
17
17
|
"name_placeholder": "例如 厨房灯",
|
|
18
18
|
"invert": "反转位置 %"
|
|
19
19
|
},
|
|
20
|
-
|
|
20
|
+
"advanced": {
|
|
21
21
|
"title": "高级兼容性",
|
|
22
22
|
"help": " 仅当 Matter 控制器或 KNX 执行器需要特殊处理时使用这些选项。",
|
|
23
|
+
"alexa_dedup_ms": "Alexa 去重窗口 (毫秒, 0 = 关闭)",
|
|
23
24
|
"turn_on_behavior": "开灯行为",
|
|
24
25
|
"turn_on_ignore_level": "忽略 On 后立即发送的亮度",
|
|
25
26
|
"turn_on_forward_all": "转发每个亮度命令",
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
"ignore_level_ms": "On 后忽略亮度时间 (ms)",
|
|
28
|
+
"cover_swap_open_close": "交换窗帘打开 / 关闭",
|
|
29
|
+
"cover_slider_debounce": "窗帘滑块防抖(毫秒,0 = 自适应)",
|
|
29
30
|
"cover_update_mode": "窗帘状态更新",
|
|
30
31
|
"cover_optimistic": "乐观更新 Matter 位置",
|
|
31
32
|
"cover_wait_status": "仅等待 KNX 状态",
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
"cover_status_timeout": "窗帘状态超时 (ms)"
|
|
34
|
+
},
|
|
35
|
+
"tabs": {
|
|
36
|
+
"mappings": "KNX 映射",
|
|
37
|
+
"advanced": "高级选项"
|
|
38
|
+
},
|
|
38
39
|
"functions": {
|
|
39
40
|
"fn_onoff_cmd": "开/关 命令 GA",
|
|
40
41
|
"fn_onoff_status": "开/关 状态 GA",
|
|
@@ -48,6 +49,8 @@
|
|
|
48
49
|
"fn_currenttemp": "当前温度 GA",
|
|
49
50
|
"fn_setpoint_cmd": "设定点 命令 GA",
|
|
50
51
|
"fn_setpoint_status": "设定点 状态 GA",
|
|
52
|
+
"fn_coolingsetpoint_cmd": "制冷设定点 命令 GA",
|
|
53
|
+
"fn_coolingsetpoint_status": "制冷设定点 状态 GA",
|
|
51
54
|
"fn_rgb_cmd": "RGB 颜色命令 GA (DPT 232.600)",
|
|
52
55
|
"fn_rgb_status": "RGB 颜色状态 GA (DPT 232.600)",
|
|
53
56
|
"fn_ct_cmd": "色温命令 GA(开尔文)",
|
|
@@ -52,7 +52,7 @@ const BRIDGE_TYPES = {
|
|
|
52
52
|
contactsensor: { device: ContactSensorDevice, functions: ['contact'], commandFunctions: [] },
|
|
53
53
|
// Custom construction (dedicated server behaviors), handled in createBridgedEndpoint
|
|
54
54
|
windowcovering: { device: null, functions: ['position'], commandFunctions: [] },
|
|
55
|
-
thermostat: { device: null, functions: ['currenttemp', 'setpoint'], commandFunctions: [] },
|
|
55
|
+
thermostat: { device: null, functions: ['currenttemp', 'setpoint', 'coolingsetpoint'], commandFunctions: [] },
|
|
56
56
|
rgblight: { device: null, functions: ['onoff', 'level', 'rgb'], commandFunctions: ['onoff', 'level'] },
|
|
57
57
|
colortemperaturelight: { device: null, functions: ['onoff', 'level', 'colortemp'], commandFunctions: ['onoff', 'level'] },
|
|
58
58
|
smokecoalarm: { device: null, functions: ['smoke', 'co'], commandFunctions: [] },
|
|
@@ -83,16 +83,8 @@ function knxValueToMatterPatch (def, fn, value) {
|
|
|
83
83
|
// Matter lift percent100ths: 0 = open, 10000 = closed.
|
|
84
84
|
let percent = Number(value)
|
|
85
85
|
if (Number.isNaN(percent)) return undefined
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
// Alexa compatibility mode: light brightness represents how open the cover is.
|
|
89
|
-
// Keep the KNX convention at this boundary and invert only the Matter view.
|
|
90
|
-
const openness = clamp(100 - percent, 0, 100)
|
|
91
|
-
return {
|
|
92
|
-
onOff: { onOff: openness > 0 },
|
|
93
|
-
levelControl: { currentLevel: clamp(Math.round(openness * 254 / 100), 1, 254) }
|
|
94
|
-
}
|
|
95
|
-
}
|
|
86
|
+
const invert = (def.invertPosition === true) !== (def.coverSwapOpenClose === true)
|
|
87
|
+
if (invert) percent = 100 - percent
|
|
96
88
|
return { windowCovering: { currentPositionLiftPercent100ths: clamp(Math.round(percent * 100), 0, 10000) } }
|
|
97
89
|
}
|
|
98
90
|
case 'rgb': {
|
|
@@ -123,7 +115,12 @@ function knxValueToMatterPatch (def, fn, value) {
|
|
|
123
115
|
case 'setpoint': {
|
|
124
116
|
const t = Number(value)
|
|
125
117
|
if (Number.isNaN(t)) return undefined
|
|
126
|
-
return { thermostat: { occupiedHeatingSetpoint: clamp(Math.round(t * 100), 700, 3000) } }
|
|
118
|
+
return { thermostat: { occupiedHeatingSetpoint: clamp(Math.round(t * 100), 700, 3000) } } // 7-30°C
|
|
119
|
+
}
|
|
120
|
+
case 'coolingsetpoint': {
|
|
121
|
+
const t = Number(value)
|
|
122
|
+
if (Number.isNaN(t)) return undefined
|
|
123
|
+
return { thermostat: { occupiedCoolingSetpoint: clamp(Math.round(t * 100), 1600, 3200) } } // 16-32°C
|
|
127
124
|
}
|
|
128
125
|
case 'onoff':
|
|
129
126
|
return { onOff: { onOff: truthy(value) } }
|
|
@@ -196,14 +193,25 @@ function knxValueToMatterPatch (def, fn, value) {
|
|
|
196
193
|
}
|
|
197
194
|
}
|
|
198
195
|
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
196
|
+
// Builds a ColorControlServer that captures color commands AT THE COMMAND BOUNDARY
|
|
197
|
+
// (like RawOnOffServer/RawLevelControlServer), instead of listening for attribute
|
|
198
|
+
// changes: repeated commands (e.g. "set to red" twice) are still forwarded every time,
|
|
199
|
+
// not just the first. This is possible for color because Matter controllers always
|
|
200
|
+
// change color via cluster COMMANDS (MoveToHue, MoveToSaturation, MoveToHueAndSaturation,
|
|
201
|
+
// MoveToColor) - unlike e.g. Thermostat/FanControl, where absolute values are set via a
|
|
202
|
+
// plain ATTRIBUTE WRITE with no command-level hook to intercept (verified against
|
|
203
|
+
// @matter/node's ThermostatServer/FanControlServer: only relative Move/Step-style
|
|
204
|
+
// commands exist there, not the absolute "set to X" interaction voice assistants use).
|
|
205
|
+
//
|
|
206
|
+
// Controllers set the color as hue+saturation OR as X+Y, sometimes as two separate
|
|
207
|
+
// commands (MoveToHue then MoveToSaturation): a short debounce merges those two into a
|
|
208
|
+
// single KNX RGB command. A combined MoveToHueAndSaturation/MoveToColor command already
|
|
209
|
+
// carries both values, so it flushes immediately with no debounce.
|
|
210
|
+
function createRawColorControlServer (def, onCommand) {
|
|
203
211
|
const tracker = { hue: 0, sat: 0, x: undefined, y: undefined, mode: 0, level: 254, timer: null }
|
|
204
212
|
|
|
205
213
|
const flush = () => {
|
|
206
|
-
tracker.timer = null
|
|
214
|
+
if (tracker.timer !== null) { clearTimeout(tracker.timer); tracker.timer = null }
|
|
207
215
|
try {
|
|
208
216
|
const v01 = clamp((tracker.level === null || tracker.level === undefined ? 254 : Number(tracker.level)) / 254, 0, 1)
|
|
209
217
|
let rgb
|
|
@@ -228,48 +236,49 @@ function attachColorTracker (endpoint, def, onCommand) {
|
|
|
228
236
|
tracker.timer = setTimeout(flush, 250)
|
|
229
237
|
}
|
|
230
238
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
tracker.hue = Number(
|
|
235
|
-
if (context?.offline === true) return
|
|
239
|
+
class RawColorControlServer extends ColorControlServer.with('HueSaturation', 'Xy') {
|
|
240
|
+
async moveToHue (request) {
|
|
241
|
+
await super.moveToHue(request)
|
|
242
|
+
tracker.hue = Number(request.hue)
|
|
236
243
|
tracker.mode = 0
|
|
237
244
|
schedule()
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
tracker.sat = Number(value)
|
|
244
|
-
if (context?.offline === true) return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async moveToSaturation (request) {
|
|
248
|
+
await super.moveToSaturation(request)
|
|
249
|
+
tracker.sat = Number(request.saturation)
|
|
245
250
|
tracker.mode = 0
|
|
246
251
|
schedule()
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
tracker.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
tracker.y = Number(value)
|
|
262
|
-
if (context?.offline === true) return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async moveToHueAndSaturation (request) {
|
|
255
|
+
await super.moveToHueAndSaturation(request)
|
|
256
|
+
tracker.hue = Number(request.hue)
|
|
257
|
+
tracker.sat = Number(request.saturation)
|
|
258
|
+
tracker.mode = 0
|
|
259
|
+
flush() // atomic: both values already known, no need to wait for a second command
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async moveToColor (request) {
|
|
263
|
+
await super.moveToColor(request)
|
|
264
|
+
tracker.x = Number(request.colorX)
|
|
265
|
+
tracker.y = Number(request.colorY)
|
|
263
266
|
tracker.mode = 1
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
267
|
+
flush()
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Track the brightness (for the RGB computation only: the level command has its own GA).
|
|
272
|
+
// Passive bookkeeping, not a command to forward raw, so an attribute listener is fine here.
|
|
273
|
+
const attachLevelTracker = (endpoint) => {
|
|
274
|
+
endpoint.events.levelControl.currentLevel$Changed.on((value) => {
|
|
275
|
+
try {
|
|
276
|
+
if (value !== null && value !== undefined) tracker.level = Number(value)
|
|
277
|
+
} catch (error) { /* empty */ }
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { RawColorControlServer, attachLevelTracker }
|
|
273
282
|
}
|
|
274
283
|
|
|
275
284
|
/**
|
|
@@ -285,13 +294,18 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
|
|
|
285
294
|
if (typeInfo === undefined) throw new Error(`Unsupported Matter bridge device type: ${def.type}`)
|
|
286
295
|
const name = (def.name || def.type).toString().slice(0, 32)
|
|
287
296
|
|
|
297
|
+
const stableSerial = `${serialPrefix}-${def.id}`.slice(0, 32)
|
|
288
298
|
const initialState = {
|
|
289
299
|
id: `knx-${def.id}`.slice(0, 32),
|
|
290
300
|
bridgedDeviceBasicInformation: {
|
|
291
301
|
nodeLabel: name, // This is the name Alexa/Google/Apple show and use
|
|
292
302
|
productName: name,
|
|
293
303
|
productLabel: name,
|
|
294
|
-
serialNumber:
|
|
304
|
+
serialNumber: stableSerial,
|
|
305
|
+
// Optional but recommended: some controllers (notably Apple Home) use UniqueID,
|
|
306
|
+
// rather than the endpoint number alone, as the stable cross-reset device identity.
|
|
307
|
+
// It never changes for a given def.id, matching the endpoint's own persisted 'id'.
|
|
308
|
+
uniqueId: stableSerial,
|
|
295
309
|
reachable: true
|
|
296
310
|
}
|
|
297
311
|
}
|
|
@@ -301,7 +315,7 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
|
|
|
301
315
|
// Although Matter permits an unknown (null) position, some controllers (including
|
|
302
316
|
// Alexa) then expose only Open/Close instead of percentage positioning. Start with
|
|
303
317
|
// a valid position; KNX or flow feedback replaces it as soon as it is available.
|
|
304
|
-
if (def.type === 'windowcovering'
|
|
318
|
+
if (def.type === 'windowcovering') {
|
|
305
319
|
initialState.windowCovering = {
|
|
306
320
|
currentPositionLiftPercent100ths: 0,
|
|
307
321
|
targetPositionLiftPercent100ths: 0
|
|
@@ -311,17 +325,45 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
|
|
|
311
325
|
let endpoint
|
|
312
326
|
|
|
313
327
|
// Emit controller commands at the command boundary instead of observing attribute
|
|
314
|
-
// changes. This makes the node output
|
|
315
|
-
// Off while already off)
|
|
328
|
+
// changes. This makes the node output raw: a controller re-sending a command (for
|
|
329
|
+
// example Off while already off) is still forwarded, unlike attribute-change tracking
|
|
330
|
+
// which would swallow the no-op.
|
|
331
|
+
//
|
|
332
|
+
// The one exception is a DUPLICATE burst from a single request. Just like the
|
|
333
|
+
// WindowCovering RangeController quirk handled below, Alexa maps lights to its generic
|
|
334
|
+
// Alexa.PowerController / Alexa.BrightnessController and a single "turn on" / "set to
|
|
335
|
+
// 50%" voice request can arrive here as up to two IDENTICAL cluster commands (two On, or
|
|
336
|
+
// moveToLevelWithOnOff + moveToLevel with the same level) instead of one - which made the
|
|
337
|
+
// node emit duplicate raw msgs, discussions/516. A short leading-edge dedup collapses
|
|
338
|
+
// that: the FIRST command is forwarded immediately (no added latency, unlike the cover's
|
|
339
|
+
// trailing debounce - lights must react instantly), and a command identical to the one
|
|
340
|
+
// just emitted for the same function is dropped only if it lands within the window. Any
|
|
341
|
+
// change of value (a real On->Off, or a different brightness) always passes through at
|
|
342
|
+
// once, so this suppresses only the spurious duplicate, never a genuine command.
|
|
343
|
+
// Configurable per device via the editor's Advanced tab (def.commandDedupMs): 0 disables
|
|
344
|
+
// it entirely (every command forwarded raw), a missing value uses the 1200ms default.
|
|
345
|
+
const rawDedupMs = Number(def.commandDedupMs)
|
|
346
|
+
const COMMAND_DEDUP_MS = Number.isFinite(rawDedupMs) && rawDedupMs >= 0 ? rawDedupMs : 1200
|
|
347
|
+
const lastEmittedCommand = new Map() // fn -> { value, at }
|
|
348
|
+
const isDuplicateCommand = (fn, value) => {
|
|
349
|
+
const now = Date.now()
|
|
350
|
+
const prev = lastEmittedCommand.get(fn)
|
|
351
|
+
if (prev !== undefined && prev.value === value && (now - prev.at) < COMMAND_DEDUP_MS) return true
|
|
352
|
+
lastEmittedCommand.set(fn, { value, at: now })
|
|
353
|
+
return false
|
|
354
|
+
}
|
|
355
|
+
|
|
316
356
|
const RawOnOffBase = def.type === 'onoffplug' ? OnOffServer : OnOffServer.with('Lighting')
|
|
317
357
|
class RawOnOffServer extends RawOnOffBase {
|
|
318
358
|
async on () {
|
|
319
359
|
await super.on()
|
|
360
|
+
if (isDuplicateCommand('onoff', true)) return
|
|
320
361
|
onCommand({ deviceId: def.id, fn: 'onoff', value: true, matterCommand: { cluster: 'OnOff', command: 'on' } })
|
|
321
362
|
}
|
|
322
363
|
|
|
323
364
|
async off () {
|
|
324
365
|
await super.off()
|
|
366
|
+
if (isDuplicateCommand('onoff', false)) return
|
|
325
367
|
onCommand({ deviceId: def.id, fn: 'onoff', value: false, matterCommand: { cluster: 'OnOff', command: 'off' } })
|
|
326
368
|
}
|
|
327
369
|
}
|
|
@@ -330,63 +372,75 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
|
|
|
330
372
|
async moveToLevel (request) {
|
|
331
373
|
await super.moveToLevel(request)
|
|
332
374
|
const value = clamp(Math.round(Number(request.level) * 100 / 254), 0, 100)
|
|
375
|
+
if (isDuplicateCommand('level', value)) return
|
|
333
376
|
onCommand({ deviceId: def.id, fn: 'level', value, matterCommand: { cluster: 'LevelControl', command: 'moveToLevel', request } })
|
|
334
377
|
}
|
|
335
378
|
|
|
336
379
|
async moveToLevelWithOnOff (request) {
|
|
337
380
|
await super.moveToLevelWithOnOff(request)
|
|
338
381
|
const value = clamp(Math.round(Number(request.level) * 100 / 254), 0, 100)
|
|
382
|
+
if (isDuplicateCommand('level', value)) return
|
|
339
383
|
onCommand({ deviceId: def.id, fn: 'level', value, matterCommand: { cluster: 'LevelControl', command: 'moveToLevelWithOnOff', request } })
|
|
340
384
|
}
|
|
341
385
|
}
|
|
342
386
|
|
|
343
|
-
if (def.type === 'windowcovering' && def.coverExposeAsDimmableLight === true) {
|
|
344
|
-
const invert = def.invertPosition === true
|
|
345
|
-
const emitPosition = (matterPosition, command, request) => {
|
|
346
|
-
let position = clamp(Math.round(matterPosition), 0, 100)
|
|
347
|
-
if (invert) position = 100 - position
|
|
348
|
-
onCommand({
|
|
349
|
-
deviceId: def.id,
|
|
350
|
-
fn: 'position',
|
|
351
|
-
value: position,
|
|
352
|
-
matterCommand: { cluster: command === 'on' || command === 'off' ? 'OnOff' : 'LevelControl', command, request },
|
|
353
|
-
matterDiagnostic: { handler: 'coverAsDimmableLight', command }
|
|
354
|
-
})
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
class CoverAsLightOnOffServer extends OnOffServer.with('Lighting') {
|
|
358
|
-
async on () {
|
|
359
|
-
await super.on()
|
|
360
|
-
emitPosition(0, 'on')
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
async off () {
|
|
364
|
-
await super.off()
|
|
365
|
-
emitPosition(100, 'off')
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
class CoverAsLightLevelServer extends LevelControlServer.with('Lighting', 'OnOff') {
|
|
370
|
-
async moveToLevel (request) {
|
|
371
|
-
await super.moveToLevel(request)
|
|
372
|
-
emitPosition(100 - clamp(Number(request.level) * 100 / 254, 0, 100), 'moveToLevel', request)
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
async moveToLevelWithOnOff (request) {
|
|
376
|
-
await super.moveToLevelWithOnOff(request)
|
|
377
|
-
emitPosition(100 - clamp(Number(request.level) * 100 / 254, 0, 100), 'moveToLevelWithOnOff', request)
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
endpoint = new Endpoint(DimmableLightDevice.with(CoverAsLightOnOffServer, CoverAsLightLevelServer, BridgedDeviceBasicInformationServer), initialState)
|
|
382
|
-
return endpoint
|
|
383
|
-
}
|
|
384
|
-
|
|
385
387
|
if (def.type === 'windowcovering') {
|
|
386
388
|
// The WindowCoveringServer requires the movement logic: we forward it to the KNX bus.
|
|
387
389
|
// Position feedback comes back from the KNX status GA, so we do NOT call the default
|
|
388
390
|
// implementation (which would fake the movement by jumping to the target).
|
|
389
|
-
|
|
391
|
+
// KNX DPT 5.001 already follows Matter's cover convention. `invertPosition`
|
|
392
|
+
// provides the Home Assistant/Alexa-friendly opposite percentage convention;
|
|
393
|
+
// swapping Open/Close also reverses percentage commands and reports, symmetrically.
|
|
394
|
+
const swapOpenClose = def.coverSwapOpenClose === true
|
|
395
|
+
const invert = (def.invertPosition === true) !== swapOpenClose
|
|
396
|
+
|
|
397
|
+
// Alexa DOES support the Matter WindowCovering device type - per Amazon's own docs
|
|
398
|
+
// (developer.amazon.com/.../supported-matter-device-categories.html, Closure category) -
|
|
399
|
+
// but maps it to its generic Alexa.RangeController interface, not a dedicated cover
|
|
400
|
+
// controller (unlike lights, which get Alexa.ColorController, or locks, which get
|
|
401
|
+
// Alexa.LockController). RangeController's generic "set/adjust range value" directive
|
|
402
|
+
// model plausibly explains why a single "open/close the blind" voice request can arrive
|
|
403
|
+
// here as a BURST of several GoToLiftPercentage commands stepping toward the final target,
|
|
404
|
+
// instead of one UpOrOpen/DownOrClose/GoToLiftPercentage(final) call. Forwarding every
|
|
405
|
+
// step to KNX makes the physical cover move in jerky stop-start motion. A short debounce -
|
|
406
|
+
// the same coalescing technique already used for RGB colors elsewhere in this file, and
|
|
407
|
+
// the fix independently validated in production by a very similar bridge - collapses a
|
|
408
|
+
// burst into one KNX command using only the LAST target. This adds an imperceptible delay
|
|
409
|
+
// for a single legitimate command (covers take many seconds to physically travel), and
|
|
410
|
+
// StopMotion always bypasses it below.
|
|
411
|
+
// https://github.com/Luligu/matterbridge-hass/issues/196
|
|
412
|
+
// Match Home Assistant Matter Hub's cover slider handling: the first command in a
|
|
413
|
+
// sequence waits 400 ms and subsequent rapid updates wait 150 ms. A per-node value
|
|
414
|
+
// above zero replaces both windows. `movementDebounceMs` remains as a test/backward
|
|
415
|
+
// compatibility alias for definitions created outside the Node-RED editor.
|
|
416
|
+
const configuredDebounce = Number(def.coverSliderDebounceMs) > 0
|
|
417
|
+
? Number(def.coverSliderDebounceMs)
|
|
418
|
+
: Number(def.movementDebounceMs) > 0 ? Number(def.movementDebounceMs) : 0
|
|
419
|
+
const DEBOUNCE_INITIAL_MS = 400
|
|
420
|
+
const DEBOUNCE_SUBSEQUENT_MS = 150
|
|
421
|
+
const COMMAND_SEQUENCE_THRESHOLD_MS = 600
|
|
422
|
+
let lastMovementCommandAt = 0
|
|
423
|
+
let movementTimer = null
|
|
424
|
+
let pendingMovementCommand = null
|
|
425
|
+
const flushMovement = () => {
|
|
426
|
+
movementTimer = null
|
|
427
|
+
if (pendingMovementCommand === null) return
|
|
428
|
+
const command = pendingMovementCommand
|
|
429
|
+
pendingMovementCommand = null
|
|
430
|
+
onCommand(command)
|
|
431
|
+
}
|
|
432
|
+
const scheduleMovement = (command) => {
|
|
433
|
+
const now = Date.now()
|
|
434
|
+
const firstInSequence = now - lastMovementCommandAt > COMMAND_SEQUENCE_THRESHOLD_MS
|
|
435
|
+
lastMovementCommandAt = now
|
|
436
|
+
const debounceMs = configuredDebounce > 0
|
|
437
|
+
? configuredDebounce
|
|
438
|
+
: firstInSequence ? DEBOUNCE_INITIAL_MS : DEBOUNCE_SUBSEQUENT_MS
|
|
439
|
+
pendingMovementCommand = command
|
|
440
|
+
if (movementTimer !== null) clearTimeout(movementTimer)
|
|
441
|
+
movementTimer = setTimeout(flushMovement, debounceMs)
|
|
442
|
+
}
|
|
443
|
+
|
|
390
444
|
class KnxCoverServer extends WindowCoveringServer.with('Lift', 'PositionAwareLift') {
|
|
391
445
|
async handleMovement (type, reversed, direction, targetPercent100ths) {
|
|
392
446
|
try {
|
|
@@ -401,19 +455,38 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
|
|
|
401
455
|
: direction === MovementDirection.Open ? 'Open' : direction === MovementDirection.Close ? 'Close' : 'Unknown',
|
|
402
456
|
targetPercent100ths: targetPercent100ths ?? null
|
|
403
457
|
}
|
|
404
|
-
if (
|
|
405
|
-
|
|
458
|
+
if (targetPercent100ths === undefined || targetPercent100ths === null) return
|
|
459
|
+
// NOTE: 'direction' is NOT a usable signal here. matter.js's WindowCoveringServer
|
|
460
|
+
// resolves MovementDirection.DefinedByPosition (the direction goToLiftPercentage()
|
|
461
|
+
// actually passes) into a concrete Open/Close BEFORE calling handleMovement,
|
|
462
|
+
// by comparing targetPercent100ths against the current position (see
|
|
463
|
+
// #prepareMovement in @matter/node's WindowCoveringServer.ts). Since this bridge
|
|
464
|
+
// always starts with a known (non-null) position, handleMovement never receives
|
|
465
|
+
// DefinedByPosition in practice - every percentage command looked exactly like a
|
|
466
|
+
// plain Open/Close command. targetPercent100ths, on the other hand, is always
|
|
467
|
+
// populated when PositionAwareLift is supported (0/10000 for plain Up/Down too),
|
|
468
|
+
// so classify on that instead. Credit: till69, github.com/Supergiovane/
|
|
469
|
+
// node-red-contrib-knx-ultimate/discussions/516#discussioncomment-17633430
|
|
470
|
+
const target = Number(targetPercent100ths)
|
|
471
|
+
if (target <= 0 || target >= 10000) {
|
|
472
|
+
// Full travel: KNX DPT 1.008 (0 = up/open, 1 = down/close). Not affected by
|
|
473
|
+
// invertPosition, which only rescales the percentage GA convention.
|
|
474
|
+
const downOrClose = target >= 10000
|
|
475
|
+
scheduleMovement({ deviceId: def.id, fn: 'updown', value: swapOpenClose ? !downOrClose : downOrClose, matterDiagnostic })
|
|
476
|
+
} else {
|
|
477
|
+
let percent = Math.round(target / 100)
|
|
406
478
|
if (invert) percent = 100 - percent
|
|
407
|
-
|
|
408
|
-
} else if (direction === MovementDirection.Open || direction === MovementDirection.Close) {
|
|
409
|
-
// KNX DPT 1.008: 0 = up/open, 1 = down/close
|
|
410
|
-
onCommand({ deviceId: def.id, fn: 'updown', value: direction === MovementDirection.Close, matterDiagnostic })
|
|
479
|
+
scheduleMovement({ deviceId: def.id, fn: 'position', value: clamp(percent, 0, 100), matterDiagnostic })
|
|
411
480
|
}
|
|
412
481
|
} catch (error) { /* empty */ }
|
|
413
482
|
}
|
|
414
483
|
|
|
415
484
|
async handleStopMovement () {
|
|
416
485
|
try {
|
|
486
|
+
// Stop is always immediate: drop any pending debounced movement so a stale queued
|
|
487
|
+
// command from before the stop cannot fire afterwards.
|
|
488
|
+
if (movementTimer !== null) { clearTimeout(movementTimer); movementTimer = null }
|
|
489
|
+
pendingMovementCommand = null
|
|
417
490
|
onCommand({
|
|
418
491
|
deviceId: def.id,
|
|
419
492
|
fn: 'stop',
|
|
@@ -429,21 +502,57 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
|
|
|
429
502
|
}
|
|
430
503
|
|
|
431
504
|
if (def.type === 'thermostat') {
|
|
505
|
+
// Heating/Cooling capability is auto-detected from which setpoint GA(s) the KNX node
|
|
506
|
+
// has configured (see knxUltimateMatterBridge.js#getMatterDef). Leaving both flags
|
|
507
|
+
// 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
|
+
|
|
432
515
|
initialState.thermostat = {
|
|
433
516
|
localTemperature: null,
|
|
434
|
-
|
|
435
|
-
controlSequenceOfOperation: 2,
|
|
436
|
-
|
|
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
|
+
})
|
|
437
543
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
}
|
|
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
|
+
}
|
|
553
|
+
// NOTE: SystemMode (Heat/Cool/Off/Auto) changes from a controller are accepted by the
|
|
554
|
+
// cluster but not forwarded to KNX yet: there is no single KNX DPT for HVAC mode that
|
|
555
|
+
// fits every actuator. Track it via the node's optional input PIN if you need it today.
|
|
447
556
|
return endpoint
|
|
448
557
|
}
|
|
449
558
|
|
|
@@ -460,8 +569,9 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
|
|
|
460
569
|
currentX: Math.round(0.31 * 65536),
|
|
461
570
|
currentY: Math.round(0.33 * 65536)
|
|
462
571
|
}
|
|
463
|
-
|
|
464
|
-
|
|
572
|
+
const { RawColorControlServer, attachLevelTracker } = createRawColorControlServer(def, onCommand)
|
|
573
|
+
endpoint = new Endpoint(ExtendedColorLightDevice.with(RawOnOffServer, RawLevelControlServer, RawColorControlServer, BridgedDeviceBasicInformationServer), initialState)
|
|
574
|
+
attachLevelTracker(endpoint)
|
|
465
575
|
} else if (def.type === 'colortemperaturelight') {
|
|
466
576
|
initialState.colorControl = {
|
|
467
577
|
colorMode: 2, // ColorTemperatureMireds
|
|
@@ -472,14 +582,15 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
|
|
|
472
582
|
startUpColorTemperatureMireds: 250,
|
|
473
583
|
coupleColorTempToLevelMinMireds: 153
|
|
474
584
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
if (
|
|
480
|
-
onCommand({ deviceId: def.id, fn: 'colortemp', value: Math.round(1000000 /
|
|
481
|
-
}
|
|
482
|
-
}
|
|
585
|
+
class RawColorTemperatureServer extends ColorControlServer.with('ColorTemperature') {
|
|
586
|
+
async moveToColorTemperature (request) {
|
|
587
|
+
await super.moveToColorTemperature(request)
|
|
588
|
+
const mireds = Number(request.colorTemperatureMireds)
|
|
589
|
+
if (Number.isNaN(mireds) || mireds <= 0) return
|
|
590
|
+
onCommand({ deviceId: def.id, fn: 'colortemp', value: Math.round(1000000 / mireds) }) // mireds -> Kelvin
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
endpoint = new Endpoint(ColorTemperatureLightDevice.with(RawOnOffServer, RawLevelControlServer, RawColorTemperatureServer, BridgedDeviceBasicInformationServer), initialState)
|
|
483
594
|
} else if (def.type === 'smokecoalarm') {
|
|
484
595
|
initialState.smokeCoAlarm = {
|
|
485
596
|
expressedState: 0, // Normal
|
|
@@ -516,7 +627,10 @@ function createBridgedEndpoint (def, serialPrefix, onCommand) {
|
|
|
516
627
|
percentCurrent: 0
|
|
517
628
|
}
|
|
518
629
|
endpoint = new Endpoint(FanDevice.with(BridgedDeviceBasicInformationServer), initialState)
|
|
519
|
-
// Speed changes from the controller (percent write or fan mode change)
|
|
630
|
+
// Speed changes from the controller (percent write or fan mode change). Like the
|
|
631
|
+
// Thermostat setpoints above, FanControlServer exposes no command to intercept for
|
|
632
|
+
// percentSetting (@matter/node's FanControlServer overrides nothing beyond
|
|
633
|
+
// initialize()) - it is attribute-write only, so this cannot be made "raw" either.
|
|
520
634
|
endpoint.events.fanControl.percentSetting$Changed.on((value, oldValue, context) => {
|
|
521
635
|
try {
|
|
522
636
|
if (context?.offline === true) return
|