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.
@@ -17,24 +17,25 @@
17
17
  "name_placeholder": "es. Luce cucina",
18
18
  "invert": "Inverti posizione %"
19
19
  },
20
- "advanced": {
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
- "ignore_level_ms": "Ignora luminosità dopo On (ms)",
27
- "cover_expose_as_light": "Esponi tapparella come luce dimmerabile (workaround Alexa)",
28
- "cover_expose_as_light_help": "Usa questa opzione solo se Alexa non imposta la percentuale della tapparella. La luminosità indica quanto è aperta; gli indirizzi di gruppo KNX non cambiano.",
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
- "cover_status_timeout": "Timeout stato tapparella (ms)"
33
- },
34
- "tabs": {
35
- "mappings": "Mappature KNX",
36
- "advanced": "Opzioni avanzate"
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
- | 温控器(采暖) | 当前温度 GA、设定点命令/状态 GA (DPT 9.001) |
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` 后立即发送的亮度命令。窗帘可以在命令后乐观更新 Matter 位置,并在 KNX 状态 GA 报告真实位置时再修正。
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
- "advanced": {
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
- "ignore_level_ms": "On 后忽略亮度时间 (ms)",
27
- "cover_expose_as_light": "将窗帘显示为可调光灯(Alexa 兼容方案)",
28
- "cover_expose_as_light_help": "仅当 Alexa 无法设置窗帘百分比时使用。亮度表示窗帘开启程度;KNX 组地址保持不变。",
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
- "cover_status_timeout": "窗帘状态超时 (ms)"
33
- },
34
- "tabs": {
35
- "mappings": "KNX 映射",
36
- "advanced": "高级选项"
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
- if (def.invertPosition === true) percent = 100 - percent
87
- if (def.type === 'windowcovering' && def.coverExposeAsDimmableLight === true) {
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
- // Wires the ColorControl events of an RGB light and coalesces them into a single
200
- // KNX RGB command. Controllers set the color as hue+saturation OR as X+Y (two separate
201
- // attribute reports each): a short debounce merges them before computing the RGB value.
202
- function attachColorTracker (endpoint, def, onCommand) {
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
- endpoint.events.colorControl.currentHue$Changed.on((value, oldValue, context) => {
232
- try {
233
- if (value === null || value === undefined) return
234
- tracker.hue = Number(value)
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
- } catch (error) { /* empty */ }
239
- })
240
- endpoint.events.colorControl.currentSaturation$Changed.on((value, oldValue, context) => {
241
- try {
242
- if (value === null || value === undefined) return
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
- } catch (error) { /* empty */ }
248
- })
249
- endpoint.events.colorControl.currentX$Changed.on((value, oldValue, context) => {
250
- try {
251
- if (value === null || value === undefined) return
252
- tracker.x = Number(value)
253
- if (context?.offline === true) return
254
- tracker.mode = 1
255
- schedule()
256
- } catch (error) { /* empty */ }
257
- })
258
- endpoint.events.colorControl.currentY$Changed.on((value, oldValue, context) => {
259
- try {
260
- if (value === null || value === undefined) return
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
- schedule()
265
- } catch (error) { /* empty */ }
266
- })
267
- // Track the brightness (for the RGB computation only: the level command has its own GA)
268
- endpoint.events.levelControl.currentLevel$Changed.on((value) => {
269
- try {
270
- if (value !== null && value !== undefined) tracker.level = Number(value)
271
- } catch (error) { /* empty */ }
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: `${serialPrefix}-${def.id}`.slice(0, 32),
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' && def.coverExposeAsDimmableLight !== true) {
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 truly raw: repeated commands (for example
315
- // Off while already off) are still forwarded exactly once.
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
- const invert = def.invertPosition === true
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 (direction === MovementDirection.DefinedByPosition && targetPercent100ths !== undefined && targetPercent100ths !== null) {
405
- let percent = Math.round(Number(targetPercent100ths) / 100)
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
- onCommand({ deviceId: def.id, fn: 'position', value: clamp(percent, 0, 100), matterDiagnostic })
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
- occupiedHeatingSetpoint: 2000, // 20°C
435
- controlSequenceOfOperation: 2, // HeatingOnly
436
- systemMode: 4 // Heat
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
- endpoint = new Endpoint(ThermostatDevice.with(ThermostatServer.with('Heating'), BridgedDeviceBasicInformationServer), initialState)
439
- // Setpoint changes from a Matter controller (attribute write or setpointRaiseLower command)
440
- endpoint.events.thermostat.occupiedHeatingSetpoint$Changed.on((value, oldValue, context) => {
441
- try {
442
- if (context?.offline === true) return
443
- if (value === null || value === undefined) return
444
- onCommand({ deviceId: def.id, fn: 'setpoint', value: Math.round(Number(value)) / 100 }) // centi-°C -> °C
445
- } catch (error) { /* empty */ }
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
- endpoint = new Endpoint(ExtendedColorLightDevice.with(RawOnOffServer, RawLevelControlServer, ColorControlServer.with('HueSaturation', 'Xy'), BridgedDeviceBasicInformationServer), initialState)
464
- attachColorTracker(endpoint, def, onCommand)
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
- endpoint = new Endpoint(ColorTemperatureLightDevice.with(RawOnOffServer, RawLevelControlServer, ColorControlServer.with('ColorTemperature'), BridgedDeviceBasicInformationServer), initialState)
476
- endpoint.events.colorControl.colorTemperatureMireds$Changed.on((value, oldValue, context) => {
477
- try {
478
- if (context?.offline === true) return
479
- if (value === null || value === undefined || Number(value) <= 0) return
480
- onCommand({ deviceId: def.id, fn: 'colortemp', value: Math.round(1000000 / Number(value)) }) // mireds -> Kelvin
481
- } catch (error) { /* empty */ }
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