@switchbot/homebridge-switchbot 5.0.0-beta.112 → 5.0.0-beta.114
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 +1560 -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 +373 -1
- package/dist/homebridge-ui/public/js/render.js.map +1 -1
- package/dist/homebridge-ui/public/js/render.ts +405 -2
- package/dist/platform.d.ts +1 -0
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +7 -3
- 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 +405 -2
- package/src/platform.ts +7 -3
- 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,299 @@ 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
|
+
|
|
296
|
+
for (const row of rows) {
|
|
297
|
+
const line = document.createElement('div')
|
|
298
|
+
line.style.display = 'flex'
|
|
299
|
+
line.style.alignItems = 'center'
|
|
300
|
+
line.style.justifyContent = 'space-between'
|
|
301
|
+
line.style.gap = '8px'
|
|
302
|
+
line.style.padding = '2px 0'
|
|
303
|
+
|
|
304
|
+
const label = document.createElement('span')
|
|
305
|
+
label.style.fontWeight = '600'
|
|
306
|
+
label.style.minWidth = '110px'
|
|
307
|
+
label.textContent = `${row.label}:`
|
|
308
|
+
|
|
309
|
+
const valueWrap = document.createElement('span')
|
|
310
|
+
valueWrap.style.display = 'inline-flex'
|
|
311
|
+
valueWrap.style.alignItems = 'center'
|
|
312
|
+
valueWrap.style.gap = '6px'
|
|
313
|
+
valueWrap.style.flex = '1'
|
|
314
|
+
valueWrap.style.justifyContent = 'flex-end'
|
|
315
|
+
valueWrap.style.minWidth = '0'
|
|
316
|
+
|
|
317
|
+
const value = document.createElement('span')
|
|
318
|
+
value.style.fontFamily = 'monospace'
|
|
319
|
+
value.style.fontSize = '11px'
|
|
320
|
+
value.style.opacity = '0.9'
|
|
321
|
+
value.style.whiteSpace = 'normal'
|
|
322
|
+
value.style.overflowWrap = 'anywhere'
|
|
323
|
+
value.style.wordBreak = 'break-word'
|
|
324
|
+
value.style.textAlign = 'right'
|
|
325
|
+
value.textContent = row.value
|
|
326
|
+
|
|
327
|
+
valueWrap.appendChild(value)
|
|
328
|
+
|
|
329
|
+
if (row.copyable && row.value && row.value !== 'N/A') {
|
|
330
|
+
const copyBtn = document.createElement('button')
|
|
331
|
+
copyBtn.textContent = '📋'
|
|
332
|
+
copyBtn.title = `Copy ${row.label}`
|
|
333
|
+
copyBtn.style.padding = '2px 6px'
|
|
334
|
+
copyBtn.style.fontSize = '10px'
|
|
335
|
+
copyBtn.style.lineHeight = '1'
|
|
336
|
+
copyBtn.style.background = '#e5e7eb'
|
|
337
|
+
copyBtn.style.color = '#111827'
|
|
338
|
+
|
|
339
|
+
copyBtn.onclick = async () => {
|
|
340
|
+
try {
|
|
341
|
+
await navigator.clipboard.writeText(row.value)
|
|
342
|
+
copyBtn.textContent = '✓'
|
|
343
|
+
setTimeout(() => {
|
|
344
|
+
copyBtn.textContent = '📋'
|
|
345
|
+
}, 1200)
|
|
346
|
+
} catch (_e) {
|
|
347
|
+
copyBtn.textContent = '!'
|
|
348
|
+
setTimeout(() => {
|
|
349
|
+
copyBtn.textContent = '📋'
|
|
350
|
+
}, 1200)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
valueWrap.appendChild(copyBtn)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
line.appendChild(label)
|
|
358
|
+
line.appendChild(valueWrap)
|
|
359
|
+
details.appendChild(line)
|
|
360
|
+
|
|
361
|
+
// If this is the Battery row, add a sparkline chart below
|
|
362
|
+
if (row.label === 'Battery' && Array.isArray(batteryHistory) && batteryHistory.length > 1) {
|
|
363
|
+
const chart = document.createElement('div')
|
|
364
|
+
chart.style.margin = '2px 0 8px 0'
|
|
365
|
+
chart.style.width = '100%'
|
|
366
|
+
chart.style.height = '28px'
|
|
367
|
+
chart.style.display = 'flex'
|
|
368
|
+
// SVG sparkline
|
|
369
|
+
const w = 120, h = 24, pad = 2
|
|
370
|
+
const min = Math.min(...batteryHistory.map(b => b.value), 100)
|
|
371
|
+
const max = Math.max(...batteryHistory.map(b => b.value), 0)
|
|
372
|
+
const range = max - min || 1
|
|
373
|
+
const points = batteryHistory.map((b, i) => {
|
|
374
|
+
const x = pad + (i * (w - 2 * pad)) / (batteryHistory.length - 1)
|
|
375
|
+
const y = pad + (h - 2 * pad) * (1 - (b.value - min) / range)
|
|
376
|
+
return `${x},${y}`
|
|
377
|
+
}).join(' ')
|
|
378
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
379
|
+
svg.setAttribute('width', String(w))
|
|
380
|
+
svg.setAttribute('height', String(h))
|
|
381
|
+
svg.setAttribute('viewBox', `0 0 ${w} ${h}`)
|
|
382
|
+
svg.style.display = 'block'
|
|
383
|
+
svg.style.background = '#f3f4f6'
|
|
384
|
+
svg.style.borderRadius = '3px'
|
|
385
|
+
svg.style.marginTop = '2px'
|
|
386
|
+
svg.style.boxShadow = '0 1px 2px #0001'
|
|
387
|
+
// Polyline for trend
|
|
388
|
+
const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline')
|
|
389
|
+
polyline.setAttribute('points', points)
|
|
390
|
+
polyline.setAttribute('fill', 'none')
|
|
391
|
+
polyline.setAttribute('stroke', '#2563eb')
|
|
392
|
+
polyline.setAttribute('stroke-width', '2')
|
|
393
|
+
svg.appendChild(polyline)
|
|
394
|
+
// Dots for each point
|
|
395
|
+
batteryHistory.forEach((b, i) => {
|
|
396
|
+
const x = pad + (i * (w - 2 * pad)) / (batteryHistory.length - 1)
|
|
397
|
+
const y = pad + (h - 2 * pad) * (1 - (b.value - min) / range)
|
|
398
|
+
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
|
|
399
|
+
circle.setAttribute('cx', String(x))
|
|
400
|
+
circle.setAttribute('cy', String(y))
|
|
401
|
+
circle.setAttribute('r', '2.5')
|
|
402
|
+
circle.setAttribute('fill', '#2563eb')
|
|
403
|
+
svg.appendChild(circle)
|
|
404
|
+
})
|
|
405
|
+
// Min/max labels
|
|
406
|
+
const minLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text')
|
|
407
|
+
minLabel.setAttribute('x', '2')
|
|
408
|
+
minLabel.setAttribute('y', String(h - 2))
|
|
409
|
+
minLabel.setAttribute('font-size', '9')
|
|
410
|
+
minLabel.setAttribute('fill', '#888')
|
|
411
|
+
minLabel.textContent = `${min}%`
|
|
412
|
+
svg.appendChild(minLabel)
|
|
413
|
+
const maxLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text')
|
|
414
|
+
maxLabel.setAttribute('x', String(w - 18))
|
|
415
|
+
maxLabel.setAttribute('y', '10')
|
|
416
|
+
maxLabel.setAttribute('font-size', '9')
|
|
417
|
+
maxLabel.setAttribute('fill', '#888')
|
|
418
|
+
maxLabel.textContent = `${max}%`
|
|
419
|
+
svg.appendChild(maxLabel)
|
|
420
|
+
chart.appendChild(svg)
|
|
421
|
+
details.appendChild(chart)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// --- Expose advanced/extra features dynamically ---
|
|
426
|
+
const featureKeys = [
|
|
427
|
+
'airQuality', 'pm25', 'pm10', 'voc', 'co2', 'humidity', 'temperature',
|
|
428
|
+
'preset', 'mode', 'presetMode', 'direction', 'calibration', 'multiCommand',
|
|
429
|
+
'extendedInfo', 'segmentedControl', 'features', 'capabilities', 'state',
|
|
430
|
+
]
|
|
431
|
+
const shown = new Set(rows.map(r => r.label.toLowerCase().replace(/\s/g, '')))
|
|
432
|
+
for (const key of featureKeys) {
|
|
433
|
+
if (device && device[key] !== undefined && !shown.has(key.toLowerCase())) {
|
|
434
|
+
const line = document.createElement('div')
|
|
435
|
+
line.style.display = 'flex'
|
|
436
|
+
line.style.alignItems = 'center'
|
|
437
|
+
line.style.justifyContent = 'space-between'
|
|
438
|
+
line.style.gap = '8px'
|
|
439
|
+
line.style.padding = '2px 0'
|
|
440
|
+
|
|
441
|
+
const label = document.createElement('span')
|
|
442
|
+
label.style.fontWeight = '600'
|
|
443
|
+
label.style.minWidth = '110px'
|
|
444
|
+
label.textContent = key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()) + ':'
|
|
445
|
+
|
|
446
|
+
const value = document.createElement('span')
|
|
447
|
+
value.style.fontFamily = 'monospace'
|
|
448
|
+
value.style.fontSize = '11px'
|
|
449
|
+
value.style.opacity = '0.9'
|
|
450
|
+
value.style.whiteSpace = 'normal'
|
|
451
|
+
value.style.overflowWrap = 'anywhere'
|
|
452
|
+
value.style.wordBreak = 'break-word'
|
|
453
|
+
value.style.textAlign = 'right'
|
|
454
|
+
value.textContent = typeof device[key] === 'object' ? JSON.stringify(device[key]) : String(device[key])
|
|
455
|
+
|
|
456
|
+
line.appendChild(label)
|
|
457
|
+
line.appendChild(value)
|
|
458
|
+
details.appendChild(line)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return details
|
|
463
|
+
}
|
|
464
|
+
|
|
179
465
|
export async function renderDiscoveredDevices(
|
|
180
466
|
devices: any[],
|
|
181
|
-
options: {
|
|
467
|
+
options: {
|
|
468
|
+
configuredIds?: Set<string>
|
|
469
|
+
selectedIds?: Set<string>
|
|
470
|
+
onToggleSelect?: (device: any, selected: boolean) => void
|
|
471
|
+
} = {},
|
|
182
472
|
): Promise<HTMLElement> {
|
|
183
473
|
const ul = document.createElement('ul')
|
|
184
474
|
ul.className = 'device-grid'
|
|
@@ -191,6 +481,8 @@ export async function renderDiscoveredDevices(
|
|
|
191
481
|
const { addDeviceToConfig } = await import('./discovery.js')
|
|
192
482
|
const { loadConfiguredDevices } = await import('./devices.js')
|
|
193
483
|
const configuredIds = options.configuredIds ?? new Set<string>()
|
|
484
|
+
const selectedIds = options.selectedIds ?? new Set<string>()
|
|
485
|
+
const onToggleSelect = options.onToggleSelect
|
|
194
486
|
|
|
195
487
|
for (const d of devices) {
|
|
196
488
|
const deviceId = normalizeId(d.id)
|
|
@@ -224,8 +516,76 @@ export async function renderDiscoveredDevices(
|
|
|
224
516
|
name.style.fontSize = '13px'
|
|
225
517
|
name.textContent = d.name || d.id
|
|
226
518
|
|
|
519
|
+
const selectCheckbox = document.createElement('input')
|
|
520
|
+
selectCheckbox.type = 'checkbox'
|
|
521
|
+
selectCheckbox.style.width = 'auto'
|
|
522
|
+
selectCheckbox.style.margin = '0 2px 0 0'
|
|
523
|
+
selectCheckbox.checked = selectedIds.has(deviceId)
|
|
524
|
+
if (alreadyAdded) {
|
|
525
|
+
selectCheckbox.disabled = true
|
|
526
|
+
selectCheckbox.title = 'Already configured'
|
|
527
|
+
}
|
|
528
|
+
selectCheckbox.onchange = () => {
|
|
529
|
+
onToggleSelect?.(d, selectCheckbox.checked)
|
|
530
|
+
// Notify listeners (e.g., batch buttons) of selection change
|
|
531
|
+
window.dispatchEvent(new CustomEvent('discovery-selection-changed'))
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
nameContainer.appendChild(selectCheckbox)
|
|
535
|
+
|
|
536
|
+
|
|
227
537
|
nameContainer.appendChild(name)
|
|
228
538
|
|
|
539
|
+
// Show firmware update available indicator if present
|
|
540
|
+
if (d.firmwareUpdateAvailable) {
|
|
541
|
+
const fwBadge = document.createElement('span');
|
|
542
|
+
fwBadge.textContent = 'Update Available';
|
|
543
|
+
fwBadge.style.cssText = 'background: #fb923c; color: #111; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
|
|
544
|
+
fwBadge.title = 'A firmware update is available for this device.';
|
|
545
|
+
nameContainer.appendChild(fwBadge);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Show offline/unreachable indicator if device is offline
|
|
549
|
+
let offline = false;
|
|
550
|
+
let lastSeen = d.lastSeen || d.lastseen || d.updatedAt;
|
|
551
|
+
if (typeof d.offline === 'boolean') {
|
|
552
|
+
offline = d.offline;
|
|
553
|
+
} else if (lastSeen) {
|
|
554
|
+
try {
|
|
555
|
+
const last = new Date(lastSeen).getTime();
|
|
556
|
+
if (!isNaN(last) && Date.now() - last > 1000 * 60 * 60) { // 1 hour
|
|
557
|
+
offline = true;
|
|
558
|
+
}
|
|
559
|
+
} catch {}
|
|
560
|
+
}
|
|
561
|
+
if (offline) {
|
|
562
|
+
const offlineBadge = document.createElement('span');
|
|
563
|
+
offlineBadge.textContent = 'Offline';
|
|
564
|
+
offlineBadge.style.cssText = 'background: #dc2626; color: white; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
|
|
565
|
+
offlineBadge.title = 'Device is offline or unreachable.';
|
|
566
|
+
nameContainer.appendChild(offlineBadge);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const expandedDetails = document.createElement('div')
|
|
570
|
+
expandedDetails.style.display = 'none'
|
|
571
|
+
expandedDetails.appendChild(renderDeviceDetailsPanel(d))
|
|
572
|
+
|
|
573
|
+
const expandBtn = document.createElement('button')
|
|
574
|
+
expandBtn.textContent = '▾'
|
|
575
|
+
expandBtn.title = 'Show details'
|
|
576
|
+
expandBtn.style.padding = '2px 6px'
|
|
577
|
+
expandBtn.style.fontSize = '11px'
|
|
578
|
+
expandBtn.style.marginLeft = '4px'
|
|
579
|
+
expandBtn.style.background = '#e5e7eb'
|
|
580
|
+
expandBtn.style.color = '#111827'
|
|
581
|
+
expandBtn.style.transition = 'transform 0.2s ease'
|
|
582
|
+
expandBtn.onclick = () => {
|
|
583
|
+
const isHidden = expandedDetails.style.display === 'none'
|
|
584
|
+
expandedDetails.style.display = isHidden ? 'block' : 'none'
|
|
585
|
+
expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)'
|
|
586
|
+
}
|
|
587
|
+
nameContainer.appendChild(expandBtn)
|
|
588
|
+
|
|
229
589
|
const duplicateBadge = document.createElement('span')
|
|
230
590
|
duplicateBadge.textContent = alreadyAdded ? '✓ Already Added' : '➕ New Device'
|
|
231
591
|
duplicateBadge.style.cssText = alreadyAdded
|
|
@@ -250,6 +610,18 @@ export async function renderDiscoveredDevices(
|
|
|
250
610
|
nameContainer.appendChild(renderSignalQualityBadge(d.rssi))
|
|
251
611
|
}
|
|
252
612
|
|
|
613
|
+
// Add battery warning indicator if battery < 20%
|
|
614
|
+
if (typeof d.battery === 'number' && d.battery < 20) {
|
|
615
|
+
const batteryWarn = document.createElement('span');
|
|
616
|
+
batteryWarn.textContent = `⚠️ ${d.battery}%`;
|
|
617
|
+
batteryWarn.style.cssText =
|
|
618
|
+
d.battery < 10
|
|
619
|
+
? 'background: #dc2626; color: white; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
|
|
620
|
+
: 'background: #fbbf24; color: #111; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
|
|
621
|
+
batteryWarn.title = d.battery < 10 ? 'Battery critically low' : 'Battery low';
|
|
622
|
+
nameContainer.appendChild(batteryWarn);
|
|
623
|
+
}
|
|
624
|
+
|
|
253
625
|
const details = document.createElement('div')
|
|
254
626
|
details.style.fontSize = '10px'
|
|
255
627
|
details.style.opacity = '0.7'
|
|
@@ -266,6 +638,7 @@ export async function renderDiscoveredDevices(
|
|
|
266
638
|
|
|
267
639
|
info.appendChild(nameContainer)
|
|
268
640
|
info.appendChild(details)
|
|
641
|
+
info.appendChild(expandedDetails)
|
|
269
642
|
|
|
270
643
|
const addBtn = document.createElement('button')
|
|
271
644
|
addBtn.textContent = alreadyAdded ? 'Already Added' : 'Add to Config'
|
|
@@ -312,6 +685,7 @@ export async function renderDiscoveredDevices(
|
|
|
312
685
|
actions.style.gap = '5px'
|
|
313
686
|
actions.appendChild(viewBtn)
|
|
314
687
|
actions.appendChild(addBtn)
|
|
688
|
+
actions.appendChild(createConnectionTestControls(d))
|
|
315
689
|
li.appendChild(actions)
|
|
316
690
|
ul.appendChild(li)
|
|
317
691
|
continue
|
|
@@ -327,6 +701,7 @@ export async function renderDiscoveredDevices(
|
|
|
327
701
|
actions.style.marginTop = '2px'
|
|
328
702
|
actions.style.gap = '5px'
|
|
329
703
|
actions.appendChild(addBtn)
|
|
704
|
+
actions.appendChild(createConnectionTestControls(d))
|
|
330
705
|
|
|
331
706
|
li.appendChild(info)
|
|
332
707
|
li.appendChild(actions)
|
|
@@ -533,6 +908,25 @@ export function renderDeviceList(list: any[]): void {
|
|
|
533
908
|
name.style.fontSize = '13px'
|
|
534
909
|
name.textContent = d.name || d.id
|
|
535
910
|
|
|
911
|
+
const expandedDetails = document.createElement('div')
|
|
912
|
+
expandedDetails.style.display = 'none'
|
|
913
|
+
expandedDetails.appendChild(renderDeviceDetailsPanel(d))
|
|
914
|
+
|
|
915
|
+
const expandBtn = document.createElement('button')
|
|
916
|
+
expandBtn.textContent = '▾'
|
|
917
|
+
expandBtn.title = 'Show details'
|
|
918
|
+
expandBtn.style.padding = '2px 6px'
|
|
919
|
+
expandBtn.style.fontSize = '11px'
|
|
920
|
+
expandBtn.style.marginLeft = '4px'
|
|
921
|
+
expandBtn.style.background = '#e5e7eb'
|
|
922
|
+
expandBtn.style.color = '#111827'
|
|
923
|
+
expandBtn.style.transition = 'transform 0.2s ease'
|
|
924
|
+
expandBtn.onclick = () => {
|
|
925
|
+
const isHidden = expandedDetails.style.display === 'none'
|
|
926
|
+
expandedDetails.style.display = isHidden ? 'block' : 'none'
|
|
927
|
+
expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)'
|
|
928
|
+
}
|
|
929
|
+
|
|
536
930
|
const code = document.createElement('code')
|
|
537
931
|
code.textContent = d.id
|
|
538
932
|
code.style.fontSize = '10px'
|
|
@@ -543,7 +937,14 @@ export function renderDeviceList(list: any[]): void {
|
|
|
543
937
|
code.style.wordBreak = 'break-word'
|
|
544
938
|
code.style.maxWidth = '100%'
|
|
545
939
|
|
|
546
|
-
|
|
940
|
+
const headerRow = document.createElement('div')
|
|
941
|
+
headerRow.style.display = 'inline-flex'
|
|
942
|
+
headerRow.style.alignItems = 'center'
|
|
943
|
+
headerRow.style.gap = '4px'
|
|
944
|
+
headerRow.appendChild(name)
|
|
945
|
+
headerRow.appendChild(expandBtn)
|
|
946
|
+
|
|
947
|
+
nameContainer.appendChild(headerRow)
|
|
547
948
|
nameContainer.appendChild(code)
|
|
548
949
|
|
|
549
950
|
// Add signal strength visualization if RSSI is available
|
|
@@ -564,6 +965,7 @@ export function renderDeviceList(list: any[]): void {
|
|
|
564
965
|
|
|
565
966
|
info.appendChild(nameContainer)
|
|
566
967
|
info.appendChild(meta)
|
|
968
|
+
info.appendChild(expandedDetails)
|
|
567
969
|
|
|
568
970
|
const buttons = document.createElement('div')
|
|
569
971
|
buttons.className = 'device-actions'
|
|
@@ -614,6 +1016,7 @@ export function renderDeviceList(list: any[]): void {
|
|
|
614
1016
|
|
|
615
1017
|
buttons.appendChild(editBtn)
|
|
616
1018
|
buttons.appendChild(copyBtn)
|
|
1019
|
+
buttons.appendChild(createConnectionTestControls(d))
|
|
617
1020
|
buttons.appendChild(deleteBtn)
|
|
618
1021
|
|
|
619
1022
|
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: {
|
|
@@ -97,7 +97,7 @@ const DEVICE_MATTER_CLUSTERS: Record<string, any> = {
|
|
|
97
97
|
}, // Switch in HAP, RobotVacuumCleaner in Matter
|
|
98
98
|
curtain: {
|
|
99
99
|
windowCovering: {
|
|
100
|
-
// Use
|
|
100
|
+
// Use Rollershade (0) for lift-only/non-tilt covering to match test expectation.
|
|
101
101
|
// Drapery (4) can fail validation in some stacks when TL (tilt) is not present.
|
|
102
102
|
type: 0, // Rollershade
|
|
103
103
|
},
|
|
@@ -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
|
|