@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.
- package/.github/copilot-instructions.md +39 -0
- package/dist/homebridge-ui/endpoints/devices.d.ts.map +1 -1
- package/dist/homebridge-ui/endpoints/devices.js +10 -1
- package/dist/homebridge-ui/endpoints/devices.js.map +1 -1
- package/dist/homebridge-ui/public/js/api.d.ts +5 -1
- package/dist/homebridge-ui/public/js/api.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/api.js +13 -3
- package/dist/homebridge-ui/public/js/api.js.map +1 -1
- package/dist/homebridge-ui/public/js/api.ts +13 -3
- package/dist/homebridge-ui/public/js/app.js +245 -11
- package/dist/homebridge-ui/public/js/app.js.map +3 -3
- package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/devices.js +12 -3
- package/dist/homebridge-ui/public/js/devices.js.map +1 -1
- package/dist/homebridge-ui/public/js/devices.ts +13 -3
- package/dist/homebridge-ui/public/js/discovery.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/discovery.js +13 -2
- package/dist/homebridge-ui/public/js/discovery.js.map +1 -1
- package/dist/homebridge-ui/public/js/discovery.ts +14 -1
- package/dist/homebridge-ui/public/js/modals.d.ts +5 -0
- package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/modals.js +146 -0
- package/dist/homebridge-ui/public/js/modals.js.map +1 -1
- package/dist/homebridge-ui/public/js/modals.ts +167 -0
- package/dist/homebridge-ui/public/js/render.d.ts +3 -1
- package/dist/homebridge-ui/public/js/render.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/render.js +62 -2
- package/dist/homebridge-ui/public/js/render.js.map +1 -1
- package/dist/homebridge-ui/public/js/render.ts +71 -2
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +63 -103
- package/dist/platform.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +2 -1
- package/dist/utils.js.map +1 -1
- package/docs/variables/default.html +1 -1
- package/package.json +1 -1
- package/src/homebridge-ui/endpoints/devices.ts +5 -2
- package/src/homebridge-ui/public/js/api.ts +13 -3
- package/src/homebridge-ui/public/js/devices.ts +13 -3
- package/src/homebridge-ui/public/js/discovery.ts +14 -1
- package/src/homebridge-ui/public/js/modals.ts +167 -0
- package/src/homebridge-ui/public/js/render.ts +71 -2
- package/src/platform.ts +76 -103
- package/src/utils.ts +2 -1
- package/test/matter-childbridge.spec.ts +5 -1
- package/test/matter-integration.spec.ts +41 -0
- package/test/utils.spec.ts +18 -2
- package/todo/COMPLETED.md +40 -0
- 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
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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()
|
package/test/utils.spec.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|