@switchbot/homebridge-switchbot 5.0.0-beta.110 → 5.0.0-beta.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.github/copilot-instructions.md +39 -0
  2. package/dist/homebridge-ui/endpoints/devices.d.ts.map +1 -1
  3. package/dist/homebridge-ui/endpoints/devices.js +10 -1
  4. package/dist/homebridge-ui/endpoints/devices.js.map +1 -1
  5. package/dist/homebridge-ui/public/js/api.d.ts +5 -1
  6. package/dist/homebridge-ui/public/js/api.d.ts.map +1 -1
  7. package/dist/homebridge-ui/public/js/api.js +13 -3
  8. package/dist/homebridge-ui/public/js/api.js.map +1 -1
  9. package/dist/homebridge-ui/public/js/api.ts +13 -3
  10. package/dist/homebridge-ui/public/js/app.js +245 -11
  11. package/dist/homebridge-ui/public/js/app.js.map +3 -3
  12. package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -1
  13. package/dist/homebridge-ui/public/js/devices.js +12 -3
  14. package/dist/homebridge-ui/public/js/devices.js.map +1 -1
  15. package/dist/homebridge-ui/public/js/devices.ts +13 -3
  16. package/dist/homebridge-ui/public/js/discovery.d.ts.map +1 -1
  17. package/dist/homebridge-ui/public/js/discovery.js +13 -2
  18. package/dist/homebridge-ui/public/js/discovery.js.map +1 -1
  19. package/dist/homebridge-ui/public/js/discovery.ts +14 -1
  20. package/dist/homebridge-ui/public/js/modals.d.ts +5 -0
  21. package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -1
  22. package/dist/homebridge-ui/public/js/modals.js +146 -0
  23. package/dist/homebridge-ui/public/js/modals.js.map +1 -1
  24. package/dist/homebridge-ui/public/js/modals.ts +167 -0
  25. package/dist/homebridge-ui/public/js/render.d.ts +3 -1
  26. package/dist/homebridge-ui/public/js/render.d.ts.map +1 -1
  27. package/dist/homebridge-ui/public/js/render.js +62 -2
  28. package/dist/homebridge-ui/public/js/render.js.map +1 -1
  29. package/dist/homebridge-ui/public/js/render.ts +71 -2
  30. package/dist/platform.d.ts.map +1 -1
  31. package/dist/platform.js +63 -103
  32. package/dist/platform.js.map +1 -1
  33. package/dist/utils.d.ts.map +1 -1
  34. package/dist/utils.js +2 -1
  35. package/dist/utils.js.map +1 -1
  36. package/docs/variables/default.html +1 -1
  37. package/package.json +1 -1
  38. package/src/homebridge-ui/endpoints/devices.ts +5 -2
  39. package/src/homebridge-ui/public/js/api.ts +13 -3
  40. package/src/homebridge-ui/public/js/devices.ts +13 -3
  41. package/src/homebridge-ui/public/js/discovery.ts +14 -1
  42. package/src/homebridge-ui/public/js/modals.ts +167 -0
  43. package/src/homebridge-ui/public/js/render.ts +71 -2
  44. package/src/platform.ts +76 -103
  45. package/src/utils.ts +2 -1
  46. package/test/matter-childbridge.spec.ts +5 -1
  47. package/test/matter-integration.spec.ts +41 -0
  48. package/test/utils.spec.ts +18 -2
  49. package/todo/COMPLETED.md +40 -0
  50. package/todo/TODO.md +13 -42
@@ -1,6 +1,173 @@
1
1
  import { DEVICE_TYPES } from './constants.js'
2
2
  import { uiLog } from './logger.js'
3
3
 
4
+ export async function importDiscoveredDevice(device: any): Promise<{ configDeviceName: string, configDeviceType: string, address?: string } | null> {
5
+ return new Promise((resolve) => {
6
+ const div = document.createElement('div')
7
+ div.style.position = 'fixed'
8
+ div.style.top = '0'
9
+ div.style.left = '0'
10
+ div.style.width = '100%'
11
+ div.style.height = '100%'
12
+ div.style.background = 'rgba(0,0,0,0.7)'
13
+ div.style.display = 'flex'
14
+ div.style.alignItems = 'center'
15
+ div.style.justifyContent = 'center'
16
+ div.style.zIndex = '9999'
17
+
18
+ const modal = document.createElement('div')
19
+ modal.style.background = getComputedStyle(document.body).backgroundColor
20
+ modal.style.color = getComputedStyle(document.body).color
21
+ modal.style.padding = '0'
22
+ modal.style.borderRadius = '10px'
23
+ modal.style.minWidth = '440px'
24
+ modal.style.maxWidth = '90vw'
25
+ modal.style.boxShadow = '0 8px 32px rgba(0,0,0,0.35)'
26
+ modal.style.overflow = 'hidden'
27
+ modal.style.borderTop = '3px solid var(--switchbot-red, #ef4444)'
28
+
29
+ const title = document.createElement('h3')
30
+ title.textContent = 'Import Discovered Device'
31
+ title.style.marginTop = '0'
32
+ title.style.marginBottom = '16px'
33
+ title.style.padding = '20px 20px 0'
34
+ title.style.fontSize = '18px'
35
+ title.style.fontWeight = '600'
36
+ title.style.color = 'var(--switchbot-red, #ef4444)'
37
+ title.style.letterSpacing = '-0.02em'
38
+
39
+ const contentDiv = document.createElement('div')
40
+ contentDiv.style.padding = '0 20px 20px'
41
+
42
+ const nameLabel = document.createElement('label')
43
+ nameLabel.textContent = 'Device Name'
44
+ nameLabel.style.display = 'block'
45
+ nameLabel.style.marginBottom = '6px'
46
+ nameLabel.style.fontWeight = '500'
47
+ nameLabel.style.fontSize = '12px'
48
+ nameLabel.style.color = '#6b7280'
49
+
50
+ const nameInput = document.createElement('input')
51
+ nameInput.type = 'text'
52
+ nameInput.value = device.name || device.id || ''
53
+ nameInput.style.width = '100%'
54
+ nameInput.style.marginBottom = '12px'
55
+ nameInput.style.padding = '8px 10px'
56
+ nameInput.style.borderRadius = '6px'
57
+ nameInput.style.fontSize = '14px'
58
+ nameInput.style.boxSizing = 'border-box'
59
+
60
+ const typeLabel = document.createElement('label')
61
+ typeLabel.textContent = 'Config Device Type'
62
+ typeLabel.style.display = 'block'
63
+ typeLabel.style.marginBottom = '6px'
64
+ typeLabel.style.fontWeight = '500'
65
+ typeLabel.style.fontSize = '12px'
66
+ typeLabel.style.color = '#6b7280'
67
+
68
+ const typeSelect = document.createElement('select')
69
+ typeSelect.style.width = '100%'
70
+ typeSelect.style.padding = '8px 10px'
71
+ typeSelect.style.marginBottom = '12px'
72
+ typeSelect.style.borderRadius = '6px'
73
+ typeSelect.style.fontSize = '14px'
74
+ typeSelect.style.background = getComputedStyle(nameInput).background
75
+ typeSelect.style.color = getComputedStyle(nameInput).color
76
+ typeSelect.style.border = getComputedStyle(nameInput).border
77
+ typeSelect.style.boxSizing = 'border-box'
78
+
79
+ Object.keys(DEVICE_TYPES).forEach((categoryName) => {
80
+ const optgroup = document.createElement('optgroup')
81
+ optgroup.label = categoryName
82
+
83
+ DEVICE_TYPES[categoryName as keyof typeof DEVICE_TYPES].forEach((deviceType) => {
84
+ const opt = document.createElement('option')
85
+ opt.value = deviceType
86
+ opt.text = deviceType
87
+ const detectedType = (device.type || '').toLowerCase()
88
+ opt.selected = deviceType.toLowerCase() === detectedType
89
+ optgroup.appendChild(opt)
90
+ })
91
+
92
+ typeSelect.appendChild(optgroup)
93
+ })
94
+
95
+ const macLabel = document.createElement('label')
96
+ macLabel.textContent = 'BLE MAC Address (optional)'
97
+ macLabel.style.display = 'block'
98
+ macLabel.style.marginBottom = '6px'
99
+ macLabel.style.fontWeight = '500'
100
+ macLabel.style.fontSize = '12px'
101
+ macLabel.style.color = '#6b7280'
102
+
103
+ const macInput = document.createElement('input')
104
+ macInput.type = 'text'
105
+ macInput.value = device.address || ''
106
+ macInput.placeholder = 'AA:BB:CC:DD:EE:FF'
107
+ macInput.style.width = '100%'
108
+ macInput.style.marginBottom = '12px'
109
+ macInput.style.padding = '8px 10px'
110
+ macInput.style.borderRadius = '6px'
111
+ macInput.style.fontSize = '14px'
112
+ macInput.style.boxSizing = 'border-box'
113
+
114
+ const buttons = document.createElement('div')
115
+ buttons.style.display = 'flex'
116
+ buttons.style.gap = '10px'
117
+ buttons.style.justifyContent = 'flex-end'
118
+ buttons.style.marginTop = '18px'
119
+ buttons.style.paddingTop = '18px'
120
+ buttons.style.borderTop = '1px solid rgba(0, 0, 0, 0.08)'
121
+
122
+ const cancelBtn = document.createElement('button')
123
+ cancelBtn.textContent = 'Cancel'
124
+ cancelBtn.className = 'secondary'
125
+ cancelBtn.style.background = '#6b7280'
126
+ cancelBtn.style.padding = '8px 16px'
127
+ cancelBtn.style.fontSize = '13px'
128
+
129
+ const importBtn = document.createElement('button')
130
+ importBtn.textContent = 'Add to Config'
131
+ importBtn.style.background = 'var(--switchbot-red, #ef4444)'
132
+ importBtn.style.padding = '8px 20px'
133
+ importBtn.style.fontSize = '13px'
134
+
135
+ const cleanup = (result: { configDeviceName: string, configDeviceType: string, address?: string } | null) => {
136
+ div.remove()
137
+ resolve(result)
138
+ }
139
+
140
+ cancelBtn.onclick = () => cleanup(null)
141
+ importBtn.onclick = () => cleanup({
142
+ configDeviceName: nameInput.value || device.name || device.id,
143
+ configDeviceType: typeSelect.value || device.type,
144
+ address: macInput.value || undefined,
145
+ })
146
+
147
+ div.addEventListener('click', (event) => {
148
+ if (event.target === div) {
149
+ cleanup(null)
150
+ }
151
+ })
152
+
153
+ buttons.appendChild(cancelBtn)
154
+ buttons.appendChild(importBtn)
155
+ contentDiv.appendChild(nameLabel)
156
+ contentDiv.appendChild(nameInput)
157
+ contentDiv.appendChild(typeLabel)
158
+ contentDiv.appendChild(typeSelect)
159
+ contentDiv.appendChild(macLabel)
160
+ contentDiv.appendChild(macInput)
161
+ contentDiv.appendChild(buttons)
162
+
163
+ modal.appendChild(title)
164
+ modal.appendChild(contentDiv)
165
+ div.appendChild(modal)
166
+ document.body.appendChild(div)
167
+ nameInput.focus()
168
+ })
169
+ }
170
+
4
171
  export async function editDevice(device: any): Promise<void> {
5
172
  // Create modal dialog for editing
6
173
  const div = document.createElement('div')
@@ -154,7 +154,32 @@ export function renderIRBadge(): HTMLElement {
154
154
  )
155
155
  }
156
156
 
157
- export async function renderDiscoveredDevices(devices: any[]): Promise<HTMLElement> {
157
+ function normalizeId(value: any): string {
158
+ return String(value ?? '').trim().toLowerCase()
159
+ }
160
+
161
+ function scrollToConfiguredDevice(deviceId: string): void {
162
+ const normalizedId = normalizeId(deviceId)
163
+ const target = document.querySelector(`[data-device-id="${normalizedId}"]`) as HTMLElement | null
164
+ if (!target) {
165
+ return
166
+ }
167
+
168
+ target.scrollIntoView({ behavior: 'smooth', block: 'center' })
169
+ const originalOutline = target.style.outline
170
+ const originalBackground = target.style.background
171
+ target.style.outline = '2px solid var(--switchbot-red, #ef4444)'
172
+ target.style.background = 'rgba(239, 68, 68, 0.08)'
173
+ setTimeout(() => {
174
+ target.style.outline = originalOutline
175
+ target.style.background = originalBackground
176
+ }, 1800)
177
+ }
178
+
179
+ export async function renderDiscoveredDevices(
180
+ devices: any[],
181
+ options: { configuredIds?: Set<string> } = {},
182
+ ): Promise<HTMLElement> {
158
183
  const ul = document.createElement('ul')
159
184
  ul.style.maxHeight = '400px'
160
185
  ul.style.overflowY = 'auto'
@@ -163,8 +188,13 @@ export async function renderDiscoveredDevices(devices: any[]): Promise<HTMLEleme
163
188
  ul.style.listStyle = 'none'
164
189
 
165
190
  const { addDeviceToConfig } = await import('./discovery.js')
191
+ const { loadConfiguredDevices } = await import('./devices.js')
192
+ const configuredIds = options.configuredIds ?? new Set<string>()
166
193
 
167
194
  for (const d of devices) {
195
+ const deviceId = normalizeId(d.id)
196
+ const alreadyAdded = configuredIds.has(deviceId)
197
+
168
198
  const li = document.createElement('li')
169
199
  li.className = 'device-item'
170
200
  li.style.display = 'flex'
@@ -192,6 +222,13 @@ export async function renderDiscoveredDevices(devices: any[]): Promise<HTMLEleme
192
222
 
193
223
  nameContainer.appendChild(name)
194
224
 
225
+ const duplicateBadge = document.createElement('span')
226
+ duplicateBadge.textContent = alreadyAdded ? '✓ Already Added' : '➕ New Device'
227
+ duplicateBadge.style.cssText = alreadyAdded
228
+ ? 'background: #16a34a; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600;'
229
+ : 'background: #2563eb; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600;'
230
+ nameContainer.appendChild(duplicateBadge)
231
+
195
232
  // Add connection type badge
196
233
  if (d.connectionType) {
197
234
  const badge = renderConnectionBadge(d.connectionType)
@@ -224,16 +261,47 @@ export async function renderDiscoveredDevices(devices: any[]): Promise<HTMLEleme
224
261
  info.appendChild(details)
225
262
 
226
263
  const addBtn = document.createElement('button')
227
- addBtn.textContent = '+ Add'
264
+ addBtn.textContent = alreadyAdded ? 'Already Added' : 'Add to Config'
228
265
  addBtn.style.marginLeft = '8px'
229
266
  addBtn.style.padding = '6px 14px'
230
267
  addBtn.style.fontSize = '12px'
231
268
  addBtn.style.whiteSpace = 'nowrap'
232
269
  addBtn.style.flexShrink = '0'
270
+ addBtn.disabled = alreadyAdded
271
+ if (alreadyAdded) {
272
+ addBtn.style.opacity = '0.65'
273
+ addBtn.style.cursor = 'not-allowed'
274
+ addBtn.style.background = '#6b7280'
275
+ }
233
276
  addBtn.onclick = async () => {
277
+ if (alreadyAdded) {
278
+ return
279
+ }
234
280
  await addDeviceToConfig(d)
235
281
  }
236
282
 
283
+ if (alreadyAdded) {
284
+ const viewBtn = document.createElement('button')
285
+ viewBtn.textContent = 'View in Config'
286
+ viewBtn.className = 'secondary'
287
+ viewBtn.style.marginLeft = '8px'
288
+ viewBtn.style.padding = '6px 12px'
289
+ viewBtn.style.fontSize = '12px'
290
+ viewBtn.onclick = async () => {
291
+ await loadConfiguredDevices()
292
+ scrollToConfiguredDevice(d.id)
293
+ }
294
+ li.appendChild(info)
295
+ const actions = document.createElement('div')
296
+ actions.style.display = 'flex'
297
+ actions.style.alignItems = 'center'
298
+ actions.appendChild(viewBtn)
299
+ actions.appendChild(addBtn)
300
+ li.appendChild(actions)
301
+ ul.appendChild(li)
302
+ continue
303
+ }
304
+
237
305
  li.appendChild(info)
238
306
  li.appendChild(addBtn)
239
307
  ul.appendChild(li)
@@ -416,6 +484,7 @@ export function renderDeviceList(list: any[]): void {
416
484
  for (const d of list) {
417
485
  const li = document.createElement('li')
418
486
  li.className = 'device-item'
487
+ li.setAttribute('data-device-id', normalizeId(d.id))
419
488
 
420
489
  const info = document.createElement('div')
421
490
  info.style.flex = '1'
package/src/platform.ts CHANGED
@@ -97,7 +97,9 @@ const DEVICE_MATTER_CLUSTERS: Record<string, any> = {
97
97
  }, // Switch in HAP, RobotVacuumCleaner in Matter
98
98
  curtain: {
99
99
  windowCovering: {
100
- type: 4, // Drapery - safe default for LF conformance
100
+ // Use a lift-only/non-tilt covering type to satisfy Matter conformance rules.
101
+ // Drapery (4) can fail validation in some stacks when TL (tilt) is not present.
102
+ type: 0, // Rollershade
101
103
  },
102
104
  }, // WindowCovering → WindowCovering (includes curtain3, rollershade variants via normalization)
103
105
  blindtilt: {
@@ -226,6 +228,76 @@ const DEVICE_MATTER_DEVICE_TYPE_KEYS: Record<string, string> = {
226
228
  waterdetector: 'LeakSensor',
227
229
  }
228
230
 
231
+ function normalizeTypeForMatter(typeValue: string | undefined | null): string {
232
+ const raw = String(typeValue || '').trim().toLowerCase()
233
+ if (!raw) {
234
+ return 'unknown'
235
+ }
236
+
237
+ // Vacuum variants
238
+ if (['wosweeper', 'wosweepermini', 'wosweeperminipro', 'k10+', 'k10+ pro'].includes(raw)) {
239
+ return 'vacuum'
240
+ }
241
+
242
+ // Window covering variants
243
+ if (['blindtilt', 'blind tilt', 'curtain3', 'rollershade', 'roller shade', 'worollershade', 'wo rollershade'].includes(raw)) {
244
+ return 'curtain'
245
+ }
246
+
247
+ // Plug variants
248
+ if (['plug mini (jp)', 'plug mini (us)', 'plug mini (eu)'].includes(raw)) {
249
+ return 'plug'
250
+ }
251
+
252
+ // Meter variants
253
+ if (['meterplus', 'meter plus', 'meter plus (jp)', 'meterpro', 'meter pro', 'meterpro(co2)', 'meter pro (co2)'].includes(raw)) {
254
+ return 'meter'
255
+ }
256
+
257
+ // Relay switch variants
258
+ if (['relay switch 1', 'relay switch 1pm'].includes(raw)) {
259
+ return 'relay'
260
+ }
261
+
262
+ // Water detector variants
263
+ if (['water detector', 'waterdetector'].includes(raw)) {
264
+ return 'waterdetector'
265
+ }
266
+
267
+ // Fan variants
268
+ if (['smart fan', 'circulator fan', 'battery circulator fan', 'standing circulator fan'].includes(raw)) {
269
+ return 'fan'
270
+ }
271
+
272
+ // Light variants
273
+ if (['strip light', 'strip light 3', 'rgbic neon rope light', 'rgbic neon wire rope light', 'rgbicww floor lamp', 'rgbicww strip light'].includes(raw)) {
274
+ return 'lightstrip'
275
+ }
276
+ if (['color bulb', 'ceiling light', 'ceiling light pro', 'candle warmer lamp', 'floor lamp'].includes(raw)) {
277
+ return 'light'
278
+ }
279
+
280
+ // Sensor variants
281
+ if (raw === 'motion sensor') {
282
+ return 'motion'
283
+ }
284
+ if (['contact sensor', 'presence sensor'].includes(raw)) {
285
+ return 'contact'
286
+ }
287
+
288
+ // Lock variants
289
+ if (['smart lock', 'smart lock pro', 'smart lock ultra', 'lock lite', 'keypad', 'keypad touch', 'keypad vision', 'keypad vision pro', 'lock vision pro'].includes(raw)) {
290
+ return 'lock'
291
+ }
292
+
293
+ // Climate variant
294
+ if (raw === 'humidifier2') {
295
+ return 'humidifier'
296
+ }
297
+
298
+ return raw
299
+ }
300
+
229
301
  // Factory function to create Matter handlers with Homebridge logger integration
230
302
  function createMatterHandlers(log: Logger, deviceId: string, type: string, client: any): any {
231
303
  const lowerType = type.toLowerCase()
@@ -1030,40 +1102,7 @@ export class SwitchBotHAPPlatform {
1030
1102
  _raw: raw,
1031
1103
  }
1032
1104
 
1033
- let type: string = d.type
1034
- // Normalize device type variants for consistent Matter cluster lookup
1035
- // Vacuum variants
1036
- if (['wosweeper', 'wosweepermini', 'wosweeperminipro', 'k10+', 'k10+ pro'].includes((type || '').toLowerCase())) {
1037
- type = 'vacuum'
1038
- }
1039
- // Window covering variants
1040
- if (['blindtilt', 'blind tilt', 'curtain3', 'rollershade', 'roller shade', 'worollershade', 'wo rollershade'].includes((type || '').toLowerCase())) {
1041
- type = 'curtain'
1042
- }
1043
- // Plug variants
1044
- if (['plug mini (jp)', 'plug mini (us)'].includes((type || '').toLowerCase())) {
1045
- type = 'plug'
1046
- }
1047
- // Meter variants
1048
- if (['meterplus', 'meter plus (jp)', 'meterpro', 'meterpro(co2)'].includes((type || '').toLowerCase())) {
1049
- type = 'meter'
1050
- }
1051
- // Relay switch variants
1052
- if (['relay switch 1', 'relay switch 1pm'].includes((type || '').toLowerCase())) {
1053
- type = 'relay'
1054
- }
1055
- // Water detector variants
1056
- if (['water detector'].includes((type || '').toLowerCase())) {
1057
- type = 'waterdetector'
1058
- }
1059
- // Fan variants
1060
- if (['smart fan'].includes((type || '').toLowerCase())) {
1061
- type = 'fan'
1062
- }
1063
- // Light variants
1064
- if (['strip light'].includes((type || '').toLowerCase())) {
1065
- type = 'lightstrip'
1066
- }
1105
+ const type: string = normalizeTypeForMatter(d.type)
1067
1106
 
1068
1107
  const matterSupported = !!DEVICE_MATTER_SUPPORTED[(type || '').toLowerCase()]
1069
1108
  // Auto-detect Matter from Homebridge API, allow manual override via config
@@ -1307,40 +1346,7 @@ export class SwitchBotMatterPlatform {
1307
1346
  _raw: raw,
1308
1347
  }
1309
1348
 
1310
- let type: string = d.type
1311
- // Normalize device type variants for consistent Matter cluster lookup
1312
- // Vacuum variants
1313
- if (['wosweeper', 'wosweepermini', 'wosweeperminipro', 'k10+', 'k10+ pro'].includes((type || '').toLowerCase())) {
1314
- type = 'vacuum'
1315
- }
1316
- // Window covering variants
1317
- if (['blindtilt', 'blind tilt', 'curtain3', 'rollershade', 'roller shade', 'worollershade', 'wo rollershade'].includes((type || '').toLowerCase())) {
1318
- type = 'curtain'
1319
- }
1320
- // Plug variants
1321
- if (['plug mini (jp)', 'plug mini (us)'].includes((type || '').toLowerCase())) {
1322
- type = 'plug'
1323
- }
1324
- // Meter variants
1325
- if (['meterplus', 'meter plus (jp)', 'meterpro', 'meterpro(co2)'].includes((type || '').toLowerCase())) {
1326
- type = 'meter'
1327
- }
1328
- // Relay switch variants
1329
- if (['relay switch 1', 'relay switch 1pm'].includes((type || '').toLowerCase())) {
1330
- type = 'relay'
1331
- }
1332
- // Water detector variants
1333
- if (['water detector'].includes((type || '').toLowerCase())) {
1334
- type = 'waterdetector'
1335
- }
1336
- // Fan variants
1337
- if (['smart fan'].includes((type || '').toLowerCase())) {
1338
- type = 'fan'
1339
- }
1340
- // Light variants
1341
- if (['strip light'].includes((type || '').toLowerCase())) {
1342
- type = 'lightstrip'
1343
- }
1349
+ const type: string = normalizeTypeForMatter(d.type)
1344
1350
 
1345
1351
  const matterSupported = !!DEVICE_MATTER_SUPPORTED[(type || '').toLowerCase()]
1346
1352
  // Auto-detect Matter from Homebridge API, allow manual override via config
@@ -1439,40 +1445,7 @@ export class SwitchBotMatterPlatform {
1439
1445
  continue
1440
1446
  }
1441
1447
 
1442
- let type: string = d.type
1443
- // Normalize device type variants for consistent Matter cluster lookup
1444
- // Vacuum variants
1445
- if (['wosweeper', 'wosweepermini', 'wosweeperminipro', 'k10+', 'k10+ pro'].includes((type || '').toLowerCase())) {
1446
- type = 'vacuum'
1447
- }
1448
- // Window covering variants
1449
- if (['blindtilt', 'blind tilt', 'curtain3', 'rollershade', 'roller shade', 'worollershade', 'wo rollershade'].includes((type || '').toLowerCase())) {
1450
- type = 'curtain'
1451
- }
1452
- // Plug variants
1453
- if (['plug mini (jp)', 'plug mini (us)'].includes((type || '').toLowerCase())) {
1454
- type = 'plug'
1455
- }
1456
- // Meter variants
1457
- if (['meterplus', 'meter plus (jp)', 'meterpro', 'meterpro(co2)'].includes((type || '').toLowerCase())) {
1458
- type = 'meter'
1459
- }
1460
- // Relay switch variants
1461
- if (['relay switch 1', 'relay switch 1pm'].includes((type || '').toLowerCase())) {
1462
- type = 'relay'
1463
- }
1464
- // Water detector variants
1465
- if (['water detector'].includes((type || '').toLowerCase())) {
1466
- type = 'waterdetector'
1467
- }
1468
- // Fan variants
1469
- if (['smart fan'].includes((type || '').toLowerCase())) {
1470
- type = 'fan'
1471
- }
1472
- // Light variants
1473
- if (['strip light'].includes((type || '').toLowerCase())) {
1474
- type = 'lightstrip'
1475
- }
1448
+ const type: string = normalizeTypeForMatter(d.type)
1476
1449
 
1477
1450
  const matterSupported = !!DEVICE_MATTER_SUPPORTED[(type || '').toLowerCase()]
1478
1451
  const useMatter = matterEnabled && matterSupported
package/src/utils.ts CHANGED
@@ -36,8 +36,9 @@ export function createPlatformProxy(HAPPlatform: any, MatterPlatform: any): any
36
36
  const cfg = normalizeConfig(config)
37
37
  const preferMatter = cfg.preferMatter ?? true
38
38
  const enableMatter = cfg.enableMatter ?? true
39
+ const matterAvailable = !!(api?.isMatterAvailable?.() && api?.isMatterEnabled?.())
39
40
 
40
- if (enableMatter && preferMatter && MatterPlatform) {
41
+ if (enableMatter && preferMatter && MatterPlatform && matterAvailable) {
41
42
  this.impl = new MatterPlatform(log, cfg, api)
42
43
  return this.impl
43
44
  }
@@ -31,7 +31,11 @@ describe('platform integration (Matter)', () => {
31
31
 
32
32
  // Create proxy that would choose the fake matter platform when preferred
33
33
  const Proxy = createPlatformProxy(function HAP() {}, FakeMatterPlatform)
34
- const platform = new (Proxy as any)(console, { preferMatter: true, enableMatter: true }, {})
34
+ const matterApi = {
35
+ isMatterAvailable: () => true,
36
+ isMatterEnabled: () => true,
37
+ }
38
+ const platform = new (Proxy as any)(console, { preferMatter: true, enableMatter: true }, matterApi)
35
39
 
36
40
  // Make a small device descriptor to load
37
41
  const device = { id: 'dev-m-1', type: 'light', name: 'MatterLight' }
@@ -6,6 +6,47 @@ import SwitchBotHAPPlatform, { SwitchBotMatterPlatform } from '../src/platform'
6
6
  // Expanded Matter integration harness tests
7
7
 
8
8
  describe('Matter integration harness', () => {
9
+ it('normalizes Color Bulb and Motion Sensor to Matter-supported categories', async () => {
10
+ const matterApi = makeFakeMatterApi()
11
+ const api = makeFakeApiWithMatter(matterApi)
12
+
13
+ const platform = new SwitchBotMatterPlatform(console as any, {
14
+ devices: [
15
+ { id: 'cb-1', type: 'Color Bulb', name: 'Color Bulb 1' },
16
+ { id: 'ms-1', type: 'Motion Sensor', name: 'Motion Sensor 1' },
17
+ ],
18
+ enableMatter: true,
19
+ preferMatter: true,
20
+ } as any, api as any)
21
+
22
+ await (platform as any).registerMatterAccessories()
23
+
24
+ const regs = matterApi.getRegistered()
25
+ expect(regs.length).toBeGreaterThanOrEqual(2)
26
+ const ids = regs.map((r: any) => r?.context?.deviceId)
27
+ expect(ids).toContain('cb-1')
28
+ expect(ids).toContain('ms-1')
29
+ })
30
+
31
+ it('uses conformant window covering type for curtain Matter registration', async () => {
32
+ const matterApi = makeFakeMatterApi()
33
+ const api = makeFakeApiWithMatter(matterApi)
34
+
35
+ const platform = new SwitchBotMatterPlatform(console as any, {
36
+ devices: [{ id: 'curtain-1', type: 'curtain', name: 'Curtain 1' }],
37
+ enableMatter: true,
38
+ preferMatter: true,
39
+ } as any, api as any)
40
+
41
+ await (platform as any).registerMatterAccessories()
42
+
43
+ const regs = matterApi.getRegistered()
44
+ expect(regs.length).toBeGreaterThanOrEqual(1)
45
+ const wcType = regs[0]?.clusters?.windowCovering?.type
46
+ expect(wcType).toBe(0)
47
+ expect(wcType).not.toBe(4)
48
+ })
49
+
9
50
  it('registers Matter accessories when Matter API available', async () => {
10
51
  // Instantiate the real Matter platform directly to test its registration path
11
52
  const matterApi = makeFakeMatterApi()
@@ -2,11 +2,15 @@ import { describe, it, expect } from 'vitest'
2
2
  import { createPlatformProxy } from '../src/utils'
3
3
 
4
4
  describe('createPlatformProxy', () => {
5
- it('returns Matter platform when preferMatter and enabled', () => {
5
+ it('returns Matter platform when preferMatter, enabled, and Matter API is available', () => {
6
6
  class HAP {}
7
7
  class Matter {}
8
8
  const ProxyCtor: any = createPlatformProxy(HAP, Matter)
9
- const impl = new (ProxyCtor as any)(console, { preferMatter: true, enableMatter: true }, undefined)
9
+ const api = {
10
+ isMatterAvailable: () => true,
11
+ isMatterEnabled: () => true,
12
+ }
13
+ const impl = new (ProxyCtor as any)(console, { preferMatter: true, enableMatter: true }, api)
10
14
  expect(impl).toBeInstanceOf(Matter)
11
15
  })
12
16
 
@@ -17,4 +21,16 @@ describe('createPlatformProxy', () => {
17
21
  const impl = new (ProxyCtor as any)(console, { preferMatter: false, enableMatter: true }, undefined)
18
22
  expect(impl).toBeInstanceOf(HAP)
19
23
  })
24
+
25
+ it('returns HAP platform when Matter API is unavailable', () => {
26
+ class HAP {}
27
+ class Matter {}
28
+ const ProxyCtor: any = createPlatformProxy(HAP, Matter)
29
+ const api = {
30
+ isMatterAvailable: () => false,
31
+ isMatterEnabled: () => false,
32
+ }
33
+ const impl = new (ProxyCtor as any)(console, { preferMatter: true, enableMatter: true }, api)
34
+ expect(impl).toBeInstanceOf(HAP)
35
+ })
20
36
  })
package/todo/COMPLETED.md CHANGED
@@ -23,6 +23,17 @@ This file tracks all completed features and improvements for the SwitchBot Homeb
23
23
 
24
24
  ## Bug Fixes
25
25
 
26
+ ### Matter Window Covering Conformance ✅ COMPLETED
27
+
28
+ - [x] **Fix Matter window covering Drapery enum validation error**
29
+ - [x] **Issue:** Matter accessory registration failed for Curtain devices
30
+ - [x] **Error:** `Conformance "LF & !TL": Matter does not allow enum value Drapery (ID 4) here`
31
+ - [x] **Related Runtime Failure:** `[Matter/Server] Failed to register Matter accessory ... [endpoint-behaviors] Behaviors have errors` with rollback from Matter transaction initialization
32
+ - [x] **Root Cause:** `windowCovering.type` used `Drapery` for lift-only curtain mappings
33
+ - [x] **Fix:** Updated curtain Matter default to a compliant lift-only covering enum and added regression coverage
34
+
35
+ **Status:** Curtain-type Matter accessories now register without the Drapery conformance failure.
36
+
26
37
  ### Invalid Device Type Validation ✅ COMPLETED
27
38
 
28
39
  - [x] **Fix invalid device type in config schema validation**
@@ -137,6 +148,35 @@ This file tracks all completed features and improvements for the SwitchBot Homeb
137
148
  - Real-time search box
138
149
  - All preferences saved and restored on next discovery
139
150
 
151
+ ### One-Click Device Import ✅ COMPLETED
152
+
153
+ - [x] Added "Add to Config" button for each discovered device
154
+ - [x] Added auto-populated import modal with discovered name/type defaults
155
+ - [x] Added BLE MAC pre-fill in import flow and persistence to config on single add
156
+ - [x] Added confirmation toast/status messaging on successful import
157
+
158
+ **Implementation:**
159
+ - Updated discovery row action in `render.ts` to use explicit "Add to Config" CTA
160
+ - Added `importDiscoveredDevice()` modal in `modals.ts` for prefilled import settings
161
+ - Extended single-add client payload in `api.ts` to include optional `address`, `model`, and `rssi`
162
+ - Extended `/add-device` endpoint in `devices.ts` to persist discovered metadata (MAC/model/RSSI)
163
+
164
+ **Status:** One-click import now supports prefilled configuration and BLE MAC carry-through for direct connection setup.
165
+
166
+ ### Duplicate Detection ✅ COMPLETED
167
+
168
+ - [x] Check if discovered device ID already exists in config
169
+ - [x] Show badge: "✓ Already Added" (green) or "➕ New Device" (blue)
170
+ - [x] Disable/hide "Add" button for already-added devices
171
+ - [x] Add "View in Config" link for existing devices
172
+
173
+ **Implementation:**
174
+ - Discovery view now fetches configured device IDs and tags discovered rows as existing/new
175
+ - Existing devices show `Already Added` state with disabled add action
176
+ - Existing devices include a `View in Config` action that scrolls and highlights the configured entry
177
+
178
+ **Status:** Duplicate devices are clearly identified at discovery time, preventing accidental re-adds and improving navigation to existing config entries.
179
+
140
180
  ---
141
181
 
142
182
  ## Bug Fixes & Technical Debt