@switchbot/homebridge-switchbot 5.0.0-beta.112 → 5.0.0-beta.113
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/MIGRATION.md +10 -0
- package/README.md +43 -0
- package/TODO.md +263 -0
- package/config.schema.json +27 -10
- package/dist/device-types.d.ts.map +1 -1
- package/dist/device-types.js +93 -63
- package/dist/device-types.js.map +1 -1
- package/dist/deviceFactory.d.ts.map +1 -1
- package/dist/deviceFactory.js +55 -33
- package/dist/deviceFactory.js.map +1 -1
- package/dist/devices/genericDevice.d.ts +12 -1
- package/dist/devices/genericDevice.d.ts.map +1 -1
- package/dist/devices/genericDevice.js +121 -1
- package/dist/devices/genericDevice.js.map +1 -1
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +51 -0
- package/dist/errors.js.map +1 -0
- package/dist/homebridge-ui/endpoints/devices.d.ts.map +1 -1
- package/dist/homebridge-ui/endpoints/devices.js +80 -1
- package/dist/homebridge-ui/endpoints/devices.js.map +1 -1
- package/dist/homebridge-ui/endpoints/discovery.d.ts.map +1 -1
- package/dist/homebridge-ui/endpoints/discovery.js +73 -41
- package/dist/homebridge-ui/endpoints/discovery.js.map +1 -1
- package/dist/homebridge-ui/public/css/styles.css +98 -0
- package/dist/homebridge-ui/public/index.html +57 -0
- package/dist/homebridge-ui/public/js/api.d.ts +24 -1
- package/dist/homebridge-ui/public/js/api.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/api.js +33 -2
- package/dist/homebridge-ui/public/js/api.js.map +1 -1
- package/dist/homebridge-ui/public/js/api.ts +52 -3
- package/dist/homebridge-ui/public/js/app.js +1503 -104
- package/dist/homebridge-ui/public/js/app.js.map +3 -3
- package/dist/homebridge-ui/public/js/app.ts +2 -1
- package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/devices.js +2 -0
- package/dist/homebridge-ui/public/js/devices.js.map +1 -1
- package/dist/homebridge-ui/public/js/devices.ts +13 -1
- package/dist/homebridge-ui/public/js/discovery.d.ts +1 -0
- package/dist/homebridge-ui/public/js/discovery.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/discovery.js +1223 -126
- package/dist/homebridge-ui/public/js/discovery.js.map +1 -1
- package/dist/homebridge-ui/public/js/discovery.ts +1323 -145
- package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/modals.js +42 -0
- package/dist/homebridge-ui/public/js/modals.js.map +1 -1
- package/dist/homebridge-ui/public/js/modals.ts +50 -1
- package/dist/homebridge-ui/public/js/render.d.ts +3 -0
- package/dist/homebridge-ui/public/js/render.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/render.js +311 -1
- package/dist/homebridge-ui/public/js/render.js.map +1 -1
- package/dist/homebridge-ui/public/js/render.ts +341 -2
- package/dist/platform.d.ts +1 -0
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +6 -2
- package/dist/platform.js.map +1 -1
- package/dist/switchbotClient.d.ts.map +1 -1
- package/dist/switchbotClient.js +51 -5
- package/dist/switchbotClient.js.map +1 -1
- package/docs/assets/highlight.css +9 -2
- package/docs/index.html +28 -2
- package/docs/variables/default.html +1 -1
- package/package.json +2 -2
- package/src/device-types.ts +95 -64
- package/src/deviceFactory.ts +55 -33
- package/src/devices/genericDevice.ts +113 -1
- package/src/errors.ts +43 -0
- package/src/homebridge-ui/endpoints/devices.ts +82 -1
- package/src/homebridge-ui/endpoints/discovery.ts +78 -44
- package/src/homebridge-ui/public/css/styles.css +98 -0
- package/src/homebridge-ui/public/index.html +57 -0
- package/src/homebridge-ui/public/js/api.ts +52 -3
- package/src/homebridge-ui/public/js/app.ts +2 -1
- package/src/homebridge-ui/public/js/devices.ts +13 -1
- package/src/homebridge-ui/public/js/discovery.ts +1323 -145
- package/src/homebridge-ui/public/js/modals.ts +50 -1
- package/src/homebridge-ui/public/js/render.ts +341 -2
- package/src/platform.ts +6 -2
- package/src/switchbotClient.ts +44 -9
- package/test/matter-integration.spec.ts +21 -0
- package/todo/COMPLETED.md +300 -0
- package/todo/TODO.md +80 -66
|
@@ -111,6 +111,47 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
|
|
|
111
111
|
macInput.style.fontSize = '14px'
|
|
112
112
|
macInput.style.boxSizing = 'border-box'
|
|
113
113
|
|
|
114
|
+
|
|
115
|
+
// BLE Encryption Key
|
|
116
|
+
const encryptionKeyLabel = document.createElement('label')
|
|
117
|
+
encryptionKeyLabel.textContent = 'BLE Encryption Key (optional)'
|
|
118
|
+
encryptionKeyLabel.style.display = 'block'
|
|
119
|
+
encryptionKeyLabel.style.marginBottom = '6px'
|
|
120
|
+
encryptionKeyLabel.style.fontWeight = '500'
|
|
121
|
+
encryptionKeyLabel.style.fontSize = '12px'
|
|
122
|
+
encryptionKeyLabel.style.color = '#6b7280'
|
|
123
|
+
|
|
124
|
+
const encryptionKeyInput = document.createElement('input')
|
|
125
|
+
encryptionKeyInput.type = 'password'
|
|
126
|
+
encryptionKeyInput.value = device.encryptionKey || ''
|
|
127
|
+
encryptionKeyInput.placeholder = 'Paste device BLE encryption key'
|
|
128
|
+
encryptionKeyInput.style.width = '100%'
|
|
129
|
+
encryptionKeyInput.style.marginBottom = '12px'
|
|
130
|
+
encryptionKeyInput.style.padding = '8px 10px'
|
|
131
|
+
encryptionKeyInput.style.borderRadius = '6px'
|
|
132
|
+
encryptionKeyInput.style.fontSize = '14px'
|
|
133
|
+
encryptionKeyInput.style.boxSizing = 'border-box'
|
|
134
|
+
|
|
135
|
+
// BLE Key ID
|
|
136
|
+
const keyIdLabel = document.createElement('label')
|
|
137
|
+
keyIdLabel.textContent = 'BLE Key ID (optional)'
|
|
138
|
+
keyIdLabel.style.display = 'block'
|
|
139
|
+
keyIdLabel.style.marginBottom = '6px'
|
|
140
|
+
keyIdLabel.style.fontWeight = '500'
|
|
141
|
+
keyIdLabel.style.fontSize = '12px'
|
|
142
|
+
keyIdLabel.style.color = '#6b7280'
|
|
143
|
+
|
|
144
|
+
const keyIdInput = document.createElement('input')
|
|
145
|
+
keyIdInput.type = 'text'
|
|
146
|
+
keyIdInput.value = device.keyId || ''
|
|
147
|
+
keyIdInput.placeholder = 'e.g. ff'
|
|
148
|
+
keyIdInput.style.width = '100%'
|
|
149
|
+
keyIdInput.style.marginBottom = '12px'
|
|
150
|
+
keyIdInput.style.padding = '8px 10px'
|
|
151
|
+
keyIdInput.style.borderRadius = '6px'
|
|
152
|
+
keyIdInput.style.fontSize = '14px'
|
|
153
|
+
keyIdInput.style.boxSizing = 'border-box'
|
|
154
|
+
|
|
114
155
|
const buttons = document.createElement('div')
|
|
115
156
|
buttons.style.display = 'flex'
|
|
116
157
|
buttons.style.gap = '10px'
|
|
@@ -132,7 +173,8 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
|
|
|
132
173
|
importBtn.style.padding = '8px 20px'
|
|
133
174
|
importBtn.style.fontSize = '13px'
|
|
134
175
|
|
|
135
|
-
|
|
176
|
+
|
|
177
|
+
const cleanup = (result: { configDeviceName: string, configDeviceType: string, address?: string, encryptionKey?: string, keyId?: string } | null) => {
|
|
136
178
|
div.remove()
|
|
137
179
|
resolve(result)
|
|
138
180
|
}
|
|
@@ -142,6 +184,8 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
|
|
|
142
184
|
configDeviceName: nameInput.value || device.name || device.id,
|
|
143
185
|
configDeviceType: typeSelect.value || device.type,
|
|
144
186
|
address: macInput.value || undefined,
|
|
187
|
+
encryptionKey: encryptionKeyInput.value || undefined,
|
|
188
|
+
keyId: keyIdInput.value || undefined,
|
|
145
189
|
})
|
|
146
190
|
|
|
147
191
|
div.addEventListener('click', (event) => {
|
|
@@ -150,6 +194,7 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
|
|
|
150
194
|
}
|
|
151
195
|
})
|
|
152
196
|
|
|
197
|
+
|
|
153
198
|
buttons.appendChild(cancelBtn)
|
|
154
199
|
buttons.appendChild(importBtn)
|
|
155
200
|
contentDiv.appendChild(nameLabel)
|
|
@@ -158,6 +203,10 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
|
|
|
158
203
|
contentDiv.appendChild(typeSelect)
|
|
159
204
|
contentDiv.appendChild(macLabel)
|
|
160
205
|
contentDiv.appendChild(macInput)
|
|
206
|
+
contentDiv.appendChild(encryptionKeyLabel)
|
|
207
|
+
contentDiv.appendChild(encryptionKeyInput)
|
|
208
|
+
contentDiv.appendChild(keyIdLabel)
|
|
209
|
+
contentDiv.appendChild(keyIdInput)
|
|
161
210
|
contentDiv.appendChild(buttons)
|
|
162
211
|
|
|
163
212
|
modal.appendChild(title)
|
|
@@ -176,9 +176,235 @@ function scrollToConfiguredDevice(deviceId: string): void {
|
|
|
176
176
|
}, 1800)
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
function createConnectionTestControls(device: any): HTMLElement {
|
|
180
|
+
const controls = document.createElement('div')
|
|
181
|
+
controls.style.display = 'inline-flex'
|
|
182
|
+
controls.style.alignItems = 'center'
|
|
183
|
+
controls.style.gap = '6px'
|
|
184
|
+
|
|
185
|
+
const button = document.createElement('button')
|
|
186
|
+
button.textContent = 'Test Connection'
|
|
187
|
+
button.className = 'secondary'
|
|
188
|
+
button.style.padding = '4px 9px'
|
|
189
|
+
button.style.fontSize = '11px'
|
|
190
|
+
|
|
191
|
+
const status = document.createElement('span')
|
|
192
|
+
status.style.fontSize = '10px'
|
|
193
|
+
status.style.opacity = '0.85'
|
|
194
|
+
status.style.whiteSpace = 'normal'
|
|
195
|
+
status.style.overflowWrap = 'anywhere'
|
|
196
|
+
|
|
197
|
+
button.onclick = async () => {
|
|
198
|
+
const startedAt = Date.now()
|
|
199
|
+
button.disabled = true
|
|
200
|
+
button.textContent = 'Testing...'
|
|
201
|
+
status.textContent = 'Checking...'
|
|
202
|
+
status.style.color = '#6b7280'
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const { testDeviceConnection } = await import('./api.js')
|
|
206
|
+
const result = await testDeviceConnection({
|
|
207
|
+
deviceId: String(device?.id || device?.deviceId || ''),
|
|
208
|
+
connectionType: device?.connectionType,
|
|
209
|
+
address: device?.address,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const measuredLatency = Number(result?.latencyMs) > 0
|
|
213
|
+
? Number(result.latencyMs)
|
|
214
|
+
: Date.now() - startedAt
|
|
215
|
+
|
|
216
|
+
if (result?.success) {
|
|
217
|
+
const method = result?.method || 'Auto'
|
|
218
|
+
status.textContent = `✓ ${method} · ${measuredLatency}ms`
|
|
219
|
+
status.style.color = '#16a34a'
|
|
220
|
+
} else {
|
|
221
|
+
const detail = result?.message ? ` · ${result.message}` : ''
|
|
222
|
+
status.textContent = `✗ Failed · ${measuredLatency}ms${detail}`
|
|
223
|
+
status.style.color = '#dc2626'
|
|
224
|
+
}
|
|
225
|
+
} catch (e) {
|
|
226
|
+
status.textContent = `✗ Failed · ${Date.now() - startedAt}ms`
|
|
227
|
+
status.style.color = '#dc2626'
|
|
228
|
+
} finally {
|
|
229
|
+
button.disabled = false
|
|
230
|
+
button.textContent = 'Test Connection'
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
controls.appendChild(button)
|
|
235
|
+
controls.appendChild(status)
|
|
236
|
+
return controls
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function formatLastSeen(value: any): string {
|
|
240
|
+
if (!value) {
|
|
241
|
+
return 'N/A'
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const date = new Date(value)
|
|
245
|
+
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString()
|
|
246
|
+
} catch (_e) {
|
|
247
|
+
return String(value)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function renderDeviceDetailsPanel(device: any): HTMLElement {
|
|
252
|
+
const details = document.createElement('div')
|
|
253
|
+
details.style.cssText = `
|
|
254
|
+
border-top: 1px solid #ddd;
|
|
255
|
+
padding: 8px;
|
|
256
|
+
background: #f9fafb;
|
|
257
|
+
border-radius: 4px;
|
|
258
|
+
font-size: 12px;
|
|
259
|
+
margin-top: 4px;
|
|
260
|
+
`
|
|
261
|
+
|
|
262
|
+
// --- Battery history trending ---
|
|
263
|
+
// Persist battery readings in localStorage per device
|
|
264
|
+
const batteryHistoryKey = `batteryHistory:${device?.id || device?.deviceId}`
|
|
265
|
+
let batteryHistory: Array<{ value: number, ts: number }> = []
|
|
266
|
+
try {
|
|
267
|
+
const raw = localStorage.getItem(batteryHistoryKey)
|
|
268
|
+
if (raw) batteryHistory = JSON.parse(raw)
|
|
269
|
+
} catch {}
|
|
270
|
+
const now = Date.now()
|
|
271
|
+
if (typeof device?.battery === 'number') {
|
|
272
|
+
// Only add if different from last or >1h since last
|
|
273
|
+
const last = batteryHistory[batteryHistory.length - 1]
|
|
274
|
+
if (!last || last.value !== device.battery || now - last.ts > 60 * 60 * 1000) {
|
|
275
|
+
batteryHistory.push({ value: device.battery, ts: now })
|
|
276
|
+
// Keep only last 30 entries (about a month if daily)
|
|
277
|
+
if (batteryHistory.length > 30) batteryHistory = batteryHistory.slice(-30)
|
|
278
|
+
try { localStorage.setItem(batteryHistoryKey, JSON.stringify(batteryHistory)) } catch {}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const rows: Array<{ label: string, value: string, copyable?: boolean }> = [
|
|
283
|
+
{ label: 'Device ID', value: String(device?.id || device?.deviceId || 'N/A'), copyable: !!(device?.id || device?.deviceId) },
|
|
284
|
+
{ label: 'MAC Address', value: String(device?.address || 'N/A'), copyable: !!device?.address },
|
|
285
|
+
{ label: 'Device Type', value: String(device?.type || device?.configDeviceType || 'N/A') },
|
|
286
|
+
{ label: 'Model', value: String(device?.model || 'N/A') },
|
|
287
|
+
{ label: 'Hub ID', value: String(device?.hubDeviceId || 'N/A') },
|
|
288
|
+
{ label: 'Battery', value: device?.battery !== undefined && device?.battery !== null ? `${device.battery}%` : 'N/A' },
|
|
289
|
+
{ label: 'Firmware', value: String(device?.version || device?.firmware || 'N/A') },
|
|
290
|
+
{ label: 'Cloud Service', value: device?.enabled === false ? 'Disabled' : 'Enabled' },
|
|
291
|
+
{ label: 'Last Seen', value: formatLastSeen(device?.lastSeen || device?.lastseen || device?.updatedAt) },
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
for (const row of rows) {
|
|
296
|
+
const line = document.createElement('div')
|
|
297
|
+
line.style.display = 'flex'
|
|
298
|
+
line.style.alignItems = 'center'
|
|
299
|
+
line.style.justifyContent = 'space-between'
|
|
300
|
+
line.style.gap = '8px'
|
|
301
|
+
line.style.padding = '2px 0'
|
|
302
|
+
|
|
303
|
+
const label = document.createElement('span')
|
|
304
|
+
label.style.fontWeight = '600'
|
|
305
|
+
label.style.minWidth = '110px'
|
|
306
|
+
label.textContent = `${row.label}:`
|
|
307
|
+
|
|
308
|
+
const valueWrap = document.createElement('span')
|
|
309
|
+
valueWrap.style.display = 'inline-flex'
|
|
310
|
+
valueWrap.style.alignItems = 'center'
|
|
311
|
+
valueWrap.style.gap = '6px'
|
|
312
|
+
valueWrap.style.flex = '1'
|
|
313
|
+
valueWrap.style.justifyContent = 'flex-end'
|
|
314
|
+
valueWrap.style.minWidth = '0'
|
|
315
|
+
|
|
316
|
+
const value = document.createElement('span')
|
|
317
|
+
value.style.fontFamily = 'monospace'
|
|
318
|
+
value.style.fontSize = '11px'
|
|
319
|
+
value.style.opacity = '0.9'
|
|
320
|
+
value.style.whiteSpace = 'normal'
|
|
321
|
+
value.style.overflowWrap = 'anywhere'
|
|
322
|
+
value.style.wordBreak = 'break-word'
|
|
323
|
+
value.style.textAlign = 'right'
|
|
324
|
+
value.textContent = row.value
|
|
325
|
+
|
|
326
|
+
valueWrap.appendChild(value)
|
|
327
|
+
|
|
328
|
+
if (row.copyable && row.value && row.value !== 'N/A') {
|
|
329
|
+
const copyBtn = document.createElement('button')
|
|
330
|
+
copyBtn.textContent = '📋'
|
|
331
|
+
copyBtn.title = `Copy ${row.label}`
|
|
332
|
+
copyBtn.style.padding = '2px 6px'
|
|
333
|
+
copyBtn.style.fontSize = '10px'
|
|
334
|
+
copyBtn.style.lineHeight = '1'
|
|
335
|
+
copyBtn.style.background = '#e5e7eb'
|
|
336
|
+
copyBtn.style.color = '#111827'
|
|
337
|
+
|
|
338
|
+
copyBtn.onclick = async () => {
|
|
339
|
+
try {
|
|
340
|
+
await navigator.clipboard.writeText(row.value)
|
|
341
|
+
copyBtn.textContent = '✓'
|
|
342
|
+
setTimeout(() => {
|
|
343
|
+
copyBtn.textContent = '📋'
|
|
344
|
+
}, 1200)
|
|
345
|
+
} catch (_e) {
|
|
346
|
+
copyBtn.textContent = '!'
|
|
347
|
+
setTimeout(() => {
|
|
348
|
+
copyBtn.textContent = '📋'
|
|
349
|
+
}, 1200)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
valueWrap.appendChild(copyBtn)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
line.appendChild(label)
|
|
357
|
+
line.appendChild(valueWrap)
|
|
358
|
+
details.appendChild(line)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// --- Expose advanced/extra features dynamically ---
|
|
362
|
+
const featureKeys = [
|
|
363
|
+
'airQuality', 'pm25', 'pm10', 'voc', 'co2', 'humidity', 'temperature',
|
|
364
|
+
'preset', 'mode', 'presetMode', 'direction', 'calibration', 'multiCommand',
|
|
365
|
+
'extendedInfo', 'segmentedControl', 'features', 'capabilities', 'state',
|
|
366
|
+
]
|
|
367
|
+
const shown = new Set(rows.map(r => r.label.toLowerCase().replace(/\s/g, '')))
|
|
368
|
+
for (const key of featureKeys) {
|
|
369
|
+
if (device && device[key] !== undefined && !shown.has(key.toLowerCase())) {
|
|
370
|
+
const line = document.createElement('div')
|
|
371
|
+
line.style.display = 'flex'
|
|
372
|
+
line.style.alignItems = 'center'
|
|
373
|
+
line.style.justifyContent = 'space-between'
|
|
374
|
+
line.style.gap = '8px'
|
|
375
|
+
line.style.padding = '2px 0'
|
|
376
|
+
|
|
377
|
+
const label = document.createElement('span')
|
|
378
|
+
label.style.fontWeight = '600'
|
|
379
|
+
label.style.minWidth = '110px'
|
|
380
|
+
label.textContent = key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()) + ':'
|
|
381
|
+
|
|
382
|
+
const value = document.createElement('span')
|
|
383
|
+
value.style.fontFamily = 'monospace'
|
|
384
|
+
value.style.fontSize = '11px'
|
|
385
|
+
value.style.opacity = '0.9'
|
|
386
|
+
value.style.whiteSpace = 'normal'
|
|
387
|
+
value.style.overflowWrap = 'anywhere'
|
|
388
|
+
value.style.wordBreak = 'break-word'
|
|
389
|
+
value.style.textAlign = 'right'
|
|
390
|
+
value.textContent = typeof device[key] === 'object' ? JSON.stringify(device[key]) : String(device[key])
|
|
391
|
+
|
|
392
|
+
line.appendChild(label)
|
|
393
|
+
line.appendChild(value)
|
|
394
|
+
details.appendChild(line)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return details
|
|
399
|
+
}
|
|
400
|
+
|
|
179
401
|
export async function renderDiscoveredDevices(
|
|
180
402
|
devices: any[],
|
|
181
|
-
options: {
|
|
403
|
+
options: {
|
|
404
|
+
configuredIds?: Set<string>
|
|
405
|
+
selectedIds?: Set<string>
|
|
406
|
+
onToggleSelect?: (device: any, selected: boolean) => void
|
|
407
|
+
} = {},
|
|
182
408
|
): Promise<HTMLElement> {
|
|
183
409
|
const ul = document.createElement('ul')
|
|
184
410
|
ul.className = 'device-grid'
|
|
@@ -191,6 +417,8 @@ export async function renderDiscoveredDevices(
|
|
|
191
417
|
const { addDeviceToConfig } = await import('./discovery.js')
|
|
192
418
|
const { loadConfiguredDevices } = await import('./devices.js')
|
|
193
419
|
const configuredIds = options.configuredIds ?? new Set<string>()
|
|
420
|
+
const selectedIds = options.selectedIds ?? new Set<string>()
|
|
421
|
+
const onToggleSelect = options.onToggleSelect
|
|
194
422
|
|
|
195
423
|
for (const d of devices) {
|
|
196
424
|
const deviceId = normalizeId(d.id)
|
|
@@ -224,8 +452,76 @@ export async function renderDiscoveredDevices(
|
|
|
224
452
|
name.style.fontSize = '13px'
|
|
225
453
|
name.textContent = d.name || d.id
|
|
226
454
|
|
|
455
|
+
const selectCheckbox = document.createElement('input')
|
|
456
|
+
selectCheckbox.type = 'checkbox'
|
|
457
|
+
selectCheckbox.style.width = 'auto'
|
|
458
|
+
selectCheckbox.style.margin = '0 2px 0 0'
|
|
459
|
+
selectCheckbox.checked = selectedIds.has(deviceId)
|
|
460
|
+
if (alreadyAdded) {
|
|
461
|
+
selectCheckbox.disabled = true
|
|
462
|
+
selectCheckbox.title = 'Already configured'
|
|
463
|
+
}
|
|
464
|
+
selectCheckbox.onchange = () => {
|
|
465
|
+
onToggleSelect?.(d, selectCheckbox.checked)
|
|
466
|
+
// Notify listeners (e.g., batch buttons) of selection change
|
|
467
|
+
window.dispatchEvent(new CustomEvent('discovery-selection-changed'))
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
nameContainer.appendChild(selectCheckbox)
|
|
471
|
+
|
|
472
|
+
|
|
227
473
|
nameContainer.appendChild(name)
|
|
228
474
|
|
|
475
|
+
// Show firmware update available indicator if present
|
|
476
|
+
if (d.firmwareUpdateAvailable) {
|
|
477
|
+
const fwBadge = document.createElement('span');
|
|
478
|
+
fwBadge.textContent = 'Update Available';
|
|
479
|
+
fwBadge.style.cssText = 'background: #fb923c; color: #111; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
|
|
480
|
+
fwBadge.title = 'A firmware update is available for this device.';
|
|
481
|
+
nameContainer.appendChild(fwBadge);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Show offline/unreachable indicator if device is offline
|
|
485
|
+
let offline = false;
|
|
486
|
+
let lastSeen = d.lastSeen || d.lastseen || d.updatedAt;
|
|
487
|
+
if (typeof d.offline === 'boolean') {
|
|
488
|
+
offline = d.offline;
|
|
489
|
+
} else if (lastSeen) {
|
|
490
|
+
try {
|
|
491
|
+
const last = new Date(lastSeen).getTime();
|
|
492
|
+
if (!isNaN(last) && Date.now() - last > 1000 * 60 * 60) { // 1 hour
|
|
493
|
+
offline = true;
|
|
494
|
+
}
|
|
495
|
+
} catch {}
|
|
496
|
+
}
|
|
497
|
+
if (offline) {
|
|
498
|
+
const offlineBadge = document.createElement('span');
|
|
499
|
+
offlineBadge.textContent = 'Offline';
|
|
500
|
+
offlineBadge.style.cssText = 'background: #dc2626; color: white; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
|
|
501
|
+
offlineBadge.title = 'Device is offline or unreachable.';
|
|
502
|
+
nameContainer.appendChild(offlineBadge);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const expandedDetails = document.createElement('div')
|
|
506
|
+
expandedDetails.style.display = 'none'
|
|
507
|
+
expandedDetails.appendChild(renderDeviceDetailsPanel(d))
|
|
508
|
+
|
|
509
|
+
const expandBtn = document.createElement('button')
|
|
510
|
+
expandBtn.textContent = '▾'
|
|
511
|
+
expandBtn.title = 'Show details'
|
|
512
|
+
expandBtn.style.padding = '2px 6px'
|
|
513
|
+
expandBtn.style.fontSize = '11px'
|
|
514
|
+
expandBtn.style.marginLeft = '4px'
|
|
515
|
+
expandBtn.style.background = '#e5e7eb'
|
|
516
|
+
expandBtn.style.color = '#111827'
|
|
517
|
+
expandBtn.style.transition = 'transform 0.2s ease'
|
|
518
|
+
expandBtn.onclick = () => {
|
|
519
|
+
const isHidden = expandedDetails.style.display === 'none'
|
|
520
|
+
expandedDetails.style.display = isHidden ? 'block' : 'none'
|
|
521
|
+
expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)'
|
|
522
|
+
}
|
|
523
|
+
nameContainer.appendChild(expandBtn)
|
|
524
|
+
|
|
229
525
|
const duplicateBadge = document.createElement('span')
|
|
230
526
|
duplicateBadge.textContent = alreadyAdded ? '✓ Already Added' : '➕ New Device'
|
|
231
527
|
duplicateBadge.style.cssText = alreadyAdded
|
|
@@ -250,6 +546,18 @@ export async function renderDiscoveredDevices(
|
|
|
250
546
|
nameContainer.appendChild(renderSignalQualityBadge(d.rssi))
|
|
251
547
|
}
|
|
252
548
|
|
|
549
|
+
// Add battery warning indicator if battery < 20%
|
|
550
|
+
if (typeof d.battery === 'number' && d.battery < 20) {
|
|
551
|
+
const batteryWarn = document.createElement('span');
|
|
552
|
+
batteryWarn.textContent = `⚠️ ${d.battery}%`;
|
|
553
|
+
batteryWarn.style.cssText =
|
|
554
|
+
d.battery < 10
|
|
555
|
+
? 'background: #dc2626; color: white; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
|
|
556
|
+
: 'background: #fbbf24; color: #111; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
|
|
557
|
+
batteryWarn.title = d.battery < 10 ? 'Battery critically low' : 'Battery low';
|
|
558
|
+
nameContainer.appendChild(batteryWarn);
|
|
559
|
+
}
|
|
560
|
+
|
|
253
561
|
const details = document.createElement('div')
|
|
254
562
|
details.style.fontSize = '10px'
|
|
255
563
|
details.style.opacity = '0.7'
|
|
@@ -266,6 +574,7 @@ export async function renderDiscoveredDevices(
|
|
|
266
574
|
|
|
267
575
|
info.appendChild(nameContainer)
|
|
268
576
|
info.appendChild(details)
|
|
577
|
+
info.appendChild(expandedDetails)
|
|
269
578
|
|
|
270
579
|
const addBtn = document.createElement('button')
|
|
271
580
|
addBtn.textContent = alreadyAdded ? 'Already Added' : 'Add to Config'
|
|
@@ -312,6 +621,7 @@ export async function renderDiscoveredDevices(
|
|
|
312
621
|
actions.style.gap = '5px'
|
|
313
622
|
actions.appendChild(viewBtn)
|
|
314
623
|
actions.appendChild(addBtn)
|
|
624
|
+
actions.appendChild(createConnectionTestControls(d))
|
|
315
625
|
li.appendChild(actions)
|
|
316
626
|
ul.appendChild(li)
|
|
317
627
|
continue
|
|
@@ -327,6 +637,7 @@ export async function renderDiscoveredDevices(
|
|
|
327
637
|
actions.style.marginTop = '2px'
|
|
328
638
|
actions.style.gap = '5px'
|
|
329
639
|
actions.appendChild(addBtn)
|
|
640
|
+
actions.appendChild(createConnectionTestControls(d))
|
|
330
641
|
|
|
331
642
|
li.appendChild(info)
|
|
332
643
|
li.appendChild(actions)
|
|
@@ -533,6 +844,25 @@ export function renderDeviceList(list: any[]): void {
|
|
|
533
844
|
name.style.fontSize = '13px'
|
|
534
845
|
name.textContent = d.name || d.id
|
|
535
846
|
|
|
847
|
+
const expandedDetails = document.createElement('div')
|
|
848
|
+
expandedDetails.style.display = 'none'
|
|
849
|
+
expandedDetails.appendChild(renderDeviceDetailsPanel(d))
|
|
850
|
+
|
|
851
|
+
const expandBtn = document.createElement('button')
|
|
852
|
+
expandBtn.textContent = '▾'
|
|
853
|
+
expandBtn.title = 'Show details'
|
|
854
|
+
expandBtn.style.padding = '2px 6px'
|
|
855
|
+
expandBtn.style.fontSize = '11px'
|
|
856
|
+
expandBtn.style.marginLeft = '4px'
|
|
857
|
+
expandBtn.style.background = '#e5e7eb'
|
|
858
|
+
expandBtn.style.color = '#111827'
|
|
859
|
+
expandBtn.style.transition = 'transform 0.2s ease'
|
|
860
|
+
expandBtn.onclick = () => {
|
|
861
|
+
const isHidden = expandedDetails.style.display === 'none'
|
|
862
|
+
expandedDetails.style.display = isHidden ? 'block' : 'none'
|
|
863
|
+
expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)'
|
|
864
|
+
}
|
|
865
|
+
|
|
536
866
|
const code = document.createElement('code')
|
|
537
867
|
code.textContent = d.id
|
|
538
868
|
code.style.fontSize = '10px'
|
|
@@ -543,7 +873,14 @@ export function renderDeviceList(list: any[]): void {
|
|
|
543
873
|
code.style.wordBreak = 'break-word'
|
|
544
874
|
code.style.maxWidth = '100%'
|
|
545
875
|
|
|
546
|
-
|
|
876
|
+
const headerRow = document.createElement('div')
|
|
877
|
+
headerRow.style.display = 'inline-flex'
|
|
878
|
+
headerRow.style.alignItems = 'center'
|
|
879
|
+
headerRow.style.gap = '4px'
|
|
880
|
+
headerRow.appendChild(name)
|
|
881
|
+
headerRow.appendChild(expandBtn)
|
|
882
|
+
|
|
883
|
+
nameContainer.appendChild(headerRow)
|
|
547
884
|
nameContainer.appendChild(code)
|
|
548
885
|
|
|
549
886
|
// Add signal strength visualization if RSSI is available
|
|
@@ -564,6 +901,7 @@ export function renderDeviceList(list: any[]): void {
|
|
|
564
901
|
|
|
565
902
|
info.appendChild(nameContainer)
|
|
566
903
|
info.appendChild(meta)
|
|
904
|
+
info.appendChild(expandedDetails)
|
|
567
905
|
|
|
568
906
|
const buttons = document.createElement('div')
|
|
569
907
|
buttons.className = 'device-actions'
|
|
@@ -614,6 +952,7 @@ export function renderDeviceList(list: any[]): void {
|
|
|
614
952
|
|
|
615
953
|
buttons.appendChild(editBtn)
|
|
616
954
|
buttons.appendChild(copyBtn)
|
|
955
|
+
buttons.appendChild(createConnectionTestControls(d))
|
|
617
956
|
buttons.appendChild(deleteBtn)
|
|
618
957
|
|
|
619
958
|
li.appendChild(info)
|
package/src/platform.ts
CHANGED
|
@@ -65,7 +65,7 @@ const DEVICE_MATTER_SUPPORTED: Record<string, boolean> = {
|
|
|
65
65
|
// Default Matter cluster configurations by device type
|
|
66
66
|
// Maps device types to their Matter cluster states (used when device doesn't provide clusters)
|
|
67
67
|
// Note: wosweeper/curtain/plug variants are normalized before cluster lookup (see loadDevices)
|
|
68
|
-
const DEVICE_MATTER_CLUSTERS: Record<string, any> = {
|
|
68
|
+
export const DEVICE_MATTER_CLUSTERS: Record<string, any> = {
|
|
69
69
|
// Core devices - aligned with HAP service implementations
|
|
70
70
|
bot: { onOff: { onOff: false } }, // Switch → OnOff
|
|
71
71
|
vacuum: {
|
|
@@ -132,7 +132,9 @@ const DEVICE_MATTER_CLUSTERS: Record<string, any> = {
|
|
|
132
132
|
minLevel: 0,
|
|
133
133
|
maxLevel: 254,
|
|
134
134
|
},
|
|
135
|
-
colorControl: {
|
|
135
|
+
colorControl: {
|
|
136
|
+
colorMode: 0,
|
|
137
|
+
},
|
|
136
138
|
}, // Lightbulb with color → OnOff + LevelControl + ColorControl
|
|
137
139
|
lock: {
|
|
138
140
|
doorLock: {
|
|
@@ -1094,6 +1096,8 @@ export class SwitchBotHAPPlatform {
|
|
|
1094
1096
|
id: raw.deviceId ?? raw.id,
|
|
1095
1097
|
name: raw.configDeviceName ?? raw.name,
|
|
1096
1098
|
type: raw.configDeviceType ?? raw.type ?? raw.deviceType ?? 'unknown',
|
|
1099
|
+
encryptionKey: raw.encryptionKey,
|
|
1100
|
+
keyId: raw.keyId,
|
|
1097
1101
|
_raw: raw,
|
|
1098
1102
|
}
|
|
1099
1103
|
|
package/src/switchbotClient.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SwitchBotPluginConfig } from './settings.js'
|
|
2
|
+
import { SwitchbotOperationError, SwitchbotAuthenticationError, CharacteristicMissingError } from './errors.js'
|
|
2
3
|
|
|
3
4
|
export interface ISwitchBotClient {
|
|
4
5
|
init(): Promise<void>
|
|
@@ -93,8 +94,17 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
93
94
|
if (this.client?.getDevice) {
|
|
94
95
|
try {
|
|
95
96
|
return await this.client.getDevice(id)
|
|
96
|
-
} catch (e) {
|
|
97
|
-
|
|
97
|
+
} catch (e: any) {
|
|
98
|
+
if (e instanceof SwitchbotAuthenticationError) {
|
|
99
|
+
this.logger?.error?.(`Authentication error for getDevice(${id}):`, e.message)
|
|
100
|
+
throw e
|
|
101
|
+
} else if (e instanceof SwitchbotOperationError) {
|
|
102
|
+
this.logger?.warn?.(`Operation error for getDevice(${id}):`, e.message, e.code)
|
|
103
|
+
} else if (e instanceof CharacteristicMissingError) {
|
|
104
|
+
this.logger?.warn?.(`Characteristic missing for getDevice(${id}):`, e.characteristic)
|
|
105
|
+
} else {
|
|
106
|
+
this.logger?.warn?.(`Client getDevice failed for ${id}, trying OpenAPI fallback:`, e)
|
|
107
|
+
}
|
|
98
108
|
}
|
|
99
109
|
}
|
|
100
110
|
// Fallback: call OpenAPI via HTTP
|
|
@@ -104,7 +114,7 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
104
114
|
const resp = await this.fetchWithTimeoutAndRetry(url, opts)
|
|
105
115
|
return resp.json()
|
|
106
116
|
}
|
|
107
|
-
throw new
|
|
117
|
+
throw new SwitchbotOperationError('No SwitchBot client available', 'no_client')
|
|
108
118
|
}
|
|
109
119
|
|
|
110
120
|
async getDevices(): Promise<any[]> {
|
|
@@ -149,7 +159,14 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
149
159
|
try {
|
|
150
160
|
const out = await this._doSetDeviceState(id, entry.body)
|
|
151
161
|
for (const r of entry.resolvers) r.resolve(out)
|
|
152
|
-
} catch (e) {
|
|
162
|
+
} catch (e: any) {
|
|
163
|
+
if (e instanceof SwitchbotAuthenticationError) {
|
|
164
|
+
this.logger?.error?.(`Authentication error for setDeviceState(${id}):`, e.message)
|
|
165
|
+
} else if (e instanceof SwitchbotOperationError) {
|
|
166
|
+
this.logger?.warn?.(`Operation error for setDeviceState(${id}):`, e.message, e.code)
|
|
167
|
+
} else if (e instanceof CharacteristicMissingError) {
|
|
168
|
+
this.logger?.warn?.(`Characteristic missing for setDeviceState(${id}):`, e.characteristic)
|
|
169
|
+
}
|
|
153
170
|
for (const r of entry.resolvers) r.reject(e)
|
|
154
171
|
}
|
|
155
172
|
}, this.writeDebounceMs)
|
|
@@ -166,8 +183,17 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
166
183
|
const result = await this.client.setDeviceState(id, body)
|
|
167
184
|
this.logger?.debug?.(`[${id}] client.setDeviceState() response:`, result)
|
|
168
185
|
return result
|
|
169
|
-
} catch (e) {
|
|
170
|
-
|
|
186
|
+
} catch (e: any) {
|
|
187
|
+
if (e instanceof SwitchbotAuthenticationError) {
|
|
188
|
+
this.logger?.error?.(`Authentication error for setDeviceState(${id}):`, e.message)
|
|
189
|
+
throw e
|
|
190
|
+
} else if (e instanceof SwitchbotOperationError) {
|
|
191
|
+
this.logger?.warn?.(`Operation error for setDeviceState(${id}):`, e.message, e.code)
|
|
192
|
+
} else if (e instanceof CharacteristicMissingError) {
|
|
193
|
+
this.logger?.warn?.(`Characteristic missing for setDeviceState(${id}):`, e.characteristic)
|
|
194
|
+
} else {
|
|
195
|
+
this.logger?.warn?.(`Client setDeviceState failed for ${id}, trying fallback:`, e)
|
|
196
|
+
}
|
|
171
197
|
}
|
|
172
198
|
}
|
|
173
199
|
|
|
@@ -178,8 +204,17 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
178
204
|
const result = await this.client.sendCommand(id, body)
|
|
179
205
|
this.logger?.debug?.(`[${id}] client.sendCommand() response:`, result)
|
|
180
206
|
return result
|
|
181
|
-
} catch (e) {
|
|
182
|
-
|
|
207
|
+
} catch (e: any) {
|
|
208
|
+
if (e instanceof SwitchbotAuthenticationError) {
|
|
209
|
+
this.logger?.error?.(`Authentication error for sendCommand(${id}):`, e.message)
|
|
210
|
+
throw e
|
|
211
|
+
} else if (e instanceof SwitchbotOperationError) {
|
|
212
|
+
this.logger?.warn?.(`Operation error for sendCommand(${id}):`, e.message, e.code)
|
|
213
|
+
} else if (e instanceof CharacteristicMissingError) {
|
|
214
|
+
this.logger?.warn?.(`Characteristic missing for sendCommand(${id}):`, e.characteristic)
|
|
215
|
+
} else {
|
|
216
|
+
this.logger?.warn?.(`Client sendCommand failed for ${id}, trying OpenAPI:`, e)
|
|
217
|
+
}
|
|
183
218
|
}
|
|
184
219
|
}
|
|
185
220
|
|
|
@@ -203,7 +238,7 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
203
238
|
return result
|
|
204
239
|
}
|
|
205
240
|
|
|
206
|
-
throw new
|
|
241
|
+
throw new SwitchbotOperationError('No SwitchBot client available for setDeviceState', 'no_client')
|
|
207
242
|
}
|
|
208
243
|
|
|
209
244
|
async destroy(): Promise<void> {
|
|
@@ -47,6 +47,27 @@ describe('Matter integration harness', () => {
|
|
|
47
47
|
expect(wcType).not.toBe(4)
|
|
48
48
|
})
|
|
49
49
|
|
|
50
|
+
it('registers RGBIC Neon Wire Rope Light as lightstrip with conformant ColorControl defaults', async () => {
|
|
51
|
+
const matterApi = makeFakeMatterApi()
|
|
52
|
+
const api = makeFakeApiWithMatter(matterApi)
|
|
53
|
+
|
|
54
|
+
const platform = new SwitchBotMatterPlatform(console as any, {
|
|
55
|
+
devices: [{ id: 'neon-1', type: 'RGBIC Neon Wire Rope Light', name: 'Family Room Neon Wire Rope Light' }],
|
|
56
|
+
enableMatter: true,
|
|
57
|
+
preferMatter: true,
|
|
58
|
+
} as any, api as any)
|
|
59
|
+
|
|
60
|
+
await (platform as any).registerMatterAccessories()
|
|
61
|
+
|
|
62
|
+
const regs = matterApi.getRegistered()
|
|
63
|
+
expect(regs.length).toBeGreaterThanOrEqual(1)
|
|
64
|
+
const neon = regs.find((r: any) => r?.context?.deviceId === 'neon-1')
|
|
65
|
+
expect(neon).toBeDefined()
|
|
66
|
+
expect(neon?.context?.type).toBe('lightstrip')
|
|
67
|
+
expect(neon?.clusters?.colorControl).toBeDefined()
|
|
68
|
+
expect(neon?.clusters?.colorControl?.colorMode).toBe(0)
|
|
69
|
+
})
|
|
70
|
+
|
|
50
71
|
it('registers Matter accessories when Matter API available', async () => {
|
|
51
72
|
// Instantiate the real Matter platform directly to test its registration path
|
|
52
73
|
const matterApi = makeFakeMatterApi()
|