@switchbot/homebridge-switchbot 5.0.0-beta.134 → 5.0.0-beta.136
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/config.schema.json +0 -11
- package/dist/deviceFactory.d.ts +3 -1
- package/dist/deviceFactory.d.ts.map +1 -1
- package/dist/deviceFactory.js +3 -18
- package/dist/deviceFactory.js.map +1 -1
- package/dist/devices/genericDevice.d.ts +1 -1
- package/dist/devices/genericDevice.d.ts.map +1 -1
- package/dist/devices/genericDevice.js +79 -159
- package/dist/devices/genericDevice.js.map +1 -1
- package/dist/homebridge-ui/public/index.html +107 -124
- package/dist/homebridge-ui/public/js/api.d.ts +1 -4
- package/dist/homebridge-ui/public/js/api.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/api.js +1 -7
- package/dist/homebridge-ui/public/js/api.js.map +1 -1
- package/dist/homebridge-ui/public/js/api.ts +1 -8
- package/dist/homebridge-ui/public/js/app.js +201 -90
- package/dist/homebridge-ui/public/js/app.js.map +2 -2
- package/dist/homebridge-ui/public/js/app.ts +5 -6
- package/dist/homebridge-ui/public/js/credentials.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/credentials.js +1 -1
- package/dist/homebridge-ui/public/js/credentials.js.map +1 -1
- package/dist/homebridge-ui/public/js/credentials.ts +7 -3
- package/dist/homebridge-ui/public/js/devices-delete.js +1 -1
- package/dist/homebridge-ui/public/js/devices-delete.js.map +1 -1
- package/dist/homebridge-ui/public/js/devices-delete.ts +1 -1
- package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/devices.js +0 -1
- package/dist/homebridge-ui/public/js/devices.js.map +1 -1
- package/dist/homebridge-ui/public/js/devices.ts +0 -2
- package/dist/homebridge-ui/public/js/discovery.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/discovery.js +6 -3
- package/dist/homebridge-ui/public/js/discovery.js.map +1 -1
- package/dist/homebridge-ui/public/js/discovery.ts +9 -3
- package/dist/homebridge-ui/public/js/logger.d.ts +1 -0
- package/dist/homebridge-ui/public/js/logger.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/logger.js +8 -3
- package/dist/homebridge-ui/public/js/logger.js.map +1 -1
- package/dist/homebridge-ui/public/js/logger.ts +12 -6
- package/dist/homebridge-ui/public/js/modals.d.ts +4 -0
- package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/modals.js +152 -59
- package/dist/homebridge-ui/public/js/modals.js.map +1 -1
- package/dist/homebridge-ui/public/js/modals.ts +187 -60
- 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 +63 -24
- package/dist/homebridge-ui/public/js/render.js.map +1 -1
- package/dist/homebridge-ui/public/js/render.ts +102 -51
- package/dist/homebridge-ui/utils/logger.js +1 -1
- package/dist/homebridge-ui/utils/logger.js.map +1 -1
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +40 -32
- package/dist/platform.js.map +1 -1
- package/dist/switchbotClient.d.ts +5 -5
- package/dist/switchbotClient.d.ts.map +1 -1
- package/dist/switchbotClient.js +21 -12
- package/dist/switchbotClient.js.map +1 -1
- package/docs/variables/default.html +1 -1
- package/package.json +1 -1
- package/scripts/build-ui.js +4 -3
- package/scripts/generate-matter-maps.js +34 -17
- package/scripts/sync-device-types.mjs +4 -1
- package/src/deviceFactory.ts +3 -18
- package/src/devices/genericDevice.ts +77 -164
- package/src/homebridge-ui/public/index.html +107 -124
- package/src/homebridge-ui/public/js/api.ts +1 -8
- package/src/homebridge-ui/public/js/app.ts +5 -6
- package/src/homebridge-ui/public/js/credentials.ts +7 -3
- package/src/homebridge-ui/public/js/devices-delete.ts +1 -1
- package/src/homebridge-ui/public/js/devices.ts +0 -2
- package/src/homebridge-ui/public/js/discovery.ts +9 -3
- package/src/homebridge-ui/public/js/logger.ts +12 -6
- package/src/homebridge-ui/public/js/modals.ts +187 -60
- package/src/homebridge-ui/public/js/render.ts +102 -51
- package/src/homebridge-ui/utils/logger.ts +1 -1
- package/src/platform.ts +40 -31
- package/src/switchbotClient.ts +42 -27
- package/test/accessory-restore.spec.ts +9 -8
- package/test/device-mapping.spec.ts +4 -3
- package/test/deviceFactory.spec.ts +4 -3
- package/test/e2e/run-e2e.spec.ts +6 -10
- package/test/fan-swing.spec.ts +7 -6
- package/test/helpers/matter-harness.ts +18 -7
- package/test/lock-users.spec.ts +5 -5
- package/test/matter-childbridge.spec.ts +10 -7
- package/test/matter-descriptors.spec.ts +8 -8
- package/test/matter-device-state.spec.ts +52 -36
- package/test/matter-integration.spec.ts +53 -6
- package/test/platform.integration.spec.ts +7 -5
- package/test/switchbot-client-debounce.spec.ts +36 -40
- package/test/switchbot-client-openapi.spec.ts +22 -15
- package/test/switchbotClient.spec.ts +4 -3
- package/test/utils.spec.ts +2 -1
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
// Move regexes to module scope to avoid re-compilation on every call
|
|
2
|
+
// import type { DEVICE_TYPES } from './constants.js' // Removed unused import
|
|
3
|
+
|
|
4
|
+
const SPACES_REGEX = /\s/g
|
|
5
|
+
const CAMELCASE_REGEX = /([A-Z])/g
|
|
6
|
+
const FIRST_CHAR_REGEX = /^./
|
|
2
7
|
|
|
3
8
|
/**
|
|
4
9
|
* Get RSSI signal quality level and color based on dBm value
|
|
@@ -129,7 +134,9 @@ export function renderBadge(text: string, style: string): HTMLElement {
|
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
export function renderConnectionBadge(connectionType: string): HTMLElement | null {
|
|
132
|
-
if (!connectionType) {
|
|
137
|
+
if (!connectionType) {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
133
140
|
|
|
134
141
|
const badge = renderBadge(connectionType, '')
|
|
135
142
|
|
|
@@ -265,17 +272,27 @@ export function renderDeviceDetailsPanel(device: any): HTMLElement {
|
|
|
265
272
|
let batteryHistory: Array<{ value: number, ts: number }> = []
|
|
266
273
|
try {
|
|
267
274
|
const raw = localStorage.getItem(batteryHistoryKey)
|
|
268
|
-
if (raw)
|
|
269
|
-
|
|
275
|
+
if (raw) {
|
|
276
|
+
batteryHistory = JSON.parse(raw)
|
|
277
|
+
}
|
|
278
|
+
} catch (e) {
|
|
279
|
+
// Optionally log or handle error
|
|
280
|
+
}
|
|
270
281
|
const now = Date.now()
|
|
271
282
|
if (typeof device?.battery === 'number') {
|
|
272
283
|
// Only add if different from last or >1h since last
|
|
273
|
-
const last = batteryHistory
|
|
284
|
+
const last = batteryHistory.at(-1)
|
|
274
285
|
if (!last || last.value !== device.battery || now - last.ts > 60 * 60 * 1000) {
|
|
275
286
|
batteryHistory.push({ value: device.battery, ts: now })
|
|
276
287
|
// Keep only last 30 entries (about a month if daily)
|
|
277
|
-
if (batteryHistory.length > 30)
|
|
278
|
-
|
|
288
|
+
if (batteryHistory.length > 30) {
|
|
289
|
+
batteryHistory = batteryHistory.slice(-30)
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
localStorage.setItem(batteryHistoryKey, JSON.stringify(batteryHistory))
|
|
293
|
+
} catch (e) {
|
|
294
|
+
// Optionally log or handle error
|
|
295
|
+
}
|
|
279
296
|
}
|
|
280
297
|
}
|
|
281
298
|
|
|
@@ -291,8 +308,6 @@ export function renderDeviceDetailsPanel(device: any): HTMLElement {
|
|
|
291
308
|
{ label: 'Last Seen', value: formatLastSeen(device?.lastSeen || device?.lastseen || device?.updatedAt) },
|
|
292
309
|
]
|
|
293
310
|
|
|
294
|
-
|
|
295
|
-
|
|
296
311
|
for (const row of rows) {
|
|
297
312
|
const line = document.createElement('div')
|
|
298
313
|
line.style.display = 'flex'
|
|
@@ -366,7 +381,9 @@ export function renderDeviceDetailsPanel(device: any): HTMLElement {
|
|
|
366
381
|
chart.style.height = '28px'
|
|
367
382
|
chart.style.display = 'flex'
|
|
368
383
|
// SVG sparkline
|
|
369
|
-
const w = 120
|
|
384
|
+
const w = 120
|
|
385
|
+
const h = 24
|
|
386
|
+
const pad = 2
|
|
370
387
|
const min = Math.min(...batteryHistory.map(b => b.value), 100)
|
|
371
388
|
const max = Math.max(...batteryHistory.map(b => b.value), 0)
|
|
372
389
|
const range = max - min || 1
|
|
@@ -424,11 +441,26 @@ export function renderDeviceDetailsPanel(device: any): HTMLElement {
|
|
|
424
441
|
|
|
425
442
|
// --- Expose advanced/extra features dynamically ---
|
|
426
443
|
const featureKeys = [
|
|
427
|
-
'airQuality',
|
|
428
|
-
'
|
|
429
|
-
'
|
|
444
|
+
'airQuality',
|
|
445
|
+
'pm25',
|
|
446
|
+
'pm10',
|
|
447
|
+
'voc',
|
|
448
|
+
'co2',
|
|
449
|
+
'humidity',
|
|
450
|
+
'temperature',
|
|
451
|
+
'preset',
|
|
452
|
+
'mode',
|
|
453
|
+
'presetMode',
|
|
454
|
+
'direction',
|
|
455
|
+
'calibration',
|
|
456
|
+
'multiCommand',
|
|
457
|
+
'extendedInfo',
|
|
458
|
+
'segmentedControl',
|
|
459
|
+
'features',
|
|
460
|
+
'capabilities',
|
|
461
|
+
'state',
|
|
430
462
|
]
|
|
431
|
-
const shown = new Set(rows.map(r => r.label.toLowerCase().replace(
|
|
463
|
+
const shown = new Set(rows.map(r => r.label.toLowerCase().replace(SPACES_REGEX, '')))
|
|
432
464
|
for (const key of featureKeys) {
|
|
433
465
|
if (device && device[key] !== undefined && !shown.has(key.toLowerCase())) {
|
|
434
466
|
const line = document.createElement('div')
|
|
@@ -441,7 +473,7 @@ export function renderDeviceDetailsPanel(device: any): HTMLElement {
|
|
|
441
473
|
const label = document.createElement('span')
|
|
442
474
|
label.style.fontWeight = '600'
|
|
443
475
|
label.style.minWidth = '110px'
|
|
444
|
-
label.textContent = key.replace(
|
|
476
|
+
label.textContent = `${key.replace(CAMELCASE_REGEX, ' $1').replace(FIRST_CHAR_REGEX, s => s.toUpperCase())}:`
|
|
445
477
|
|
|
446
478
|
const value = document.createElement('span')
|
|
447
479
|
value.style.fontFamily = 'monospace'
|
|
@@ -533,37 +565,38 @@ export async function renderDiscoveredDevices(
|
|
|
533
565
|
|
|
534
566
|
nameContainer.appendChild(selectCheckbox)
|
|
535
567
|
|
|
536
|
-
|
|
537
568
|
nameContainer.appendChild(name)
|
|
538
569
|
|
|
539
570
|
// Show firmware update available indicator if present
|
|
540
571
|
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)
|
|
572
|
+
const fwBadge = document.createElement('span')
|
|
573
|
+
fwBadge.textContent = 'Update Available'
|
|
574
|
+
fwBadge.style.cssText = 'background: #fb923c; color: #111; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
|
|
575
|
+
fwBadge.title = 'A firmware update is available for this device.'
|
|
576
|
+
nameContainer.appendChild(fwBadge)
|
|
546
577
|
}
|
|
547
578
|
|
|
548
579
|
// Show offline/unreachable indicator if device is offline
|
|
549
|
-
let offline = false
|
|
550
|
-
|
|
580
|
+
let offline = false
|
|
581
|
+
const lastSeen = d.lastSeen || d.lastseen || d.updatedAt
|
|
551
582
|
if (typeof d.offline === 'boolean') {
|
|
552
|
-
offline = d.offline
|
|
583
|
+
offline = d.offline
|
|
553
584
|
} else if (lastSeen) {
|
|
554
585
|
try {
|
|
555
|
-
const last = new Date(lastSeen).getTime()
|
|
556
|
-
if (!isNaN(last)
|
|
557
|
-
|
|
586
|
+
const last = new Date(lastSeen).getTime()
|
|
587
|
+
if (!Number.isNaN(last)) {
|
|
588
|
+
if (Date.now() - last > 1000 * 60 * 60) { // 1 hour
|
|
589
|
+
offline = true
|
|
590
|
+
}
|
|
558
591
|
}
|
|
559
592
|
} catch {}
|
|
560
593
|
}
|
|
561
594
|
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)
|
|
595
|
+
const offlineBadge = document.createElement('span')
|
|
596
|
+
offlineBadge.textContent = 'Offline'
|
|
597
|
+
offlineBadge.style.cssText = 'background: #dc2626; color: white; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
|
|
598
|
+
offlineBadge.title = 'Device is offline or unreachable.'
|
|
599
|
+
nameContainer.appendChild(offlineBadge)
|
|
567
600
|
}
|
|
568
601
|
|
|
569
602
|
const expandedDetails = document.createElement('div')
|
|
@@ -596,7 +629,9 @@ export async function renderDiscoveredDevices(
|
|
|
596
629
|
// Add connection type badge
|
|
597
630
|
if (d.connectionType) {
|
|
598
631
|
const badge = renderConnectionBadge(d.connectionType)
|
|
599
|
-
if (badge) {
|
|
632
|
+
if (badge) {
|
|
633
|
+
nameContainer.appendChild(badge)
|
|
634
|
+
}
|
|
600
635
|
}
|
|
601
636
|
|
|
602
637
|
// Add IR badge if it's an IR device
|
|
@@ -612,14 +647,14 @@ export async function renderDiscoveredDevices(
|
|
|
612
647
|
|
|
613
648
|
// Add battery warning indicator if battery < 20%
|
|
614
649
|
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
|
|
650
|
+
const batteryWarn = document.createElement('span')
|
|
651
|
+
batteryWarn.textContent = `⚠️ ${d.battery}%`
|
|
652
|
+
batteryWarn.style.cssText
|
|
653
|
+
= d.battery < 10
|
|
619
654
|
? '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)
|
|
655
|
+
: 'background: #fbbf24; color: #111; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
|
|
656
|
+
batteryWarn.title = d.battery < 10 ? 'Battery critically low' : 'Battery low'
|
|
657
|
+
nameContainer.appendChild(batteryWarn)
|
|
623
658
|
}
|
|
624
659
|
|
|
625
660
|
const details = document.createElement('div')
|
|
@@ -632,8 +667,12 @@ export async function renderDiscoveredDevices(
|
|
|
632
667
|
details.style.wordBreak = 'break-word'
|
|
633
668
|
|
|
634
669
|
let detailsText = `ID: ${d.id} | Type: ${d.type} | Model: ${d.model || 'N/A'}`
|
|
635
|
-
if (d.hubDeviceId) {
|
|
636
|
-
|
|
670
|
+
if (d.hubDeviceId) {
|
|
671
|
+
detailsText += ` | Hub: ${d.hubDeviceId}`
|
|
672
|
+
}
|
|
673
|
+
if (d.address) {
|
|
674
|
+
detailsText += ` | MAC: ${d.address}`
|
|
675
|
+
}
|
|
637
676
|
details.textContent = detailsText
|
|
638
677
|
|
|
639
678
|
info.appendChild(nameContainer)
|
|
@@ -727,7 +766,7 @@ export function filterDevices(
|
|
|
727
766
|
|
|
728
767
|
// Filter by connection type
|
|
729
768
|
if (connectionType !== 'all') {
|
|
730
|
-
filtered = filtered.filter(d => {
|
|
769
|
+
filtered = filtered.filter((d) => {
|
|
731
770
|
if (connectionType === 'ir') {
|
|
732
771
|
return d.isIR === true
|
|
733
772
|
}
|
|
@@ -747,7 +786,7 @@ export function filterDevices(
|
|
|
747
786
|
// Filter by search query
|
|
748
787
|
if (searchQuery.trim()) {
|
|
749
788
|
const query = searchQuery.toLowerCase()
|
|
750
|
-
filtered = filtered.filter(d => {
|
|
789
|
+
filtered = filtered.filter((d) => {
|
|
751
790
|
const name = (d.name || '').toLowerCase()
|
|
752
791
|
const id = (d.id || '').toLowerCase()
|
|
753
792
|
const type = (d.type || '').toLowerCase()
|
|
@@ -792,11 +831,11 @@ export function sortDevices(
|
|
|
792
831
|
case 'connection': {
|
|
793
832
|
// Sort by connection type: Both > BLE > OpenAPI > Others
|
|
794
833
|
const connectionOrder: Record<string, number> = {
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
834
|
+
Both: 0,
|
|
835
|
+
BLE: 1,
|
|
836
|
+
OpenAPI: 2,
|
|
837
|
+
API: 2,
|
|
838
|
+
Unknown: 3,
|
|
800
839
|
}
|
|
801
840
|
sorted.sort((a, b) => {
|
|
802
841
|
const aOrder = connectionOrder[a.connectionType || 'Unknown'] ?? 3
|
|
@@ -844,6 +883,9 @@ export function getDiscoveryPreferences(): {
|
|
|
844
883
|
/**
|
|
845
884
|
* Save filter/sort preferences to localStorage
|
|
846
885
|
* @param preferences Preferences object to save
|
|
886
|
+
* @param preferences.connectionType Connection type filter
|
|
887
|
+
* @param preferences.sortBy Sort criterion
|
|
888
|
+
* @param preferences.searchQuery Search query string
|
|
847
889
|
*/
|
|
848
890
|
export function setDiscoveryPreferences(preferences: {
|
|
849
891
|
connectionType: 'all' | 'ble' | 'api' | 'both' | 'ir'
|
|
@@ -862,7 +904,9 @@ export function renderDeviceList(list: any[]): void {
|
|
|
862
904
|
const status = document.getElementById('status')
|
|
863
905
|
const removeAllContainer = document.getElementById('removeAllContainer')
|
|
864
906
|
|
|
865
|
-
if (!ul || !status) {
|
|
907
|
+
if (!ul || !status) {
|
|
908
|
+
return
|
|
909
|
+
}
|
|
866
910
|
|
|
867
911
|
if (!list.length) {
|
|
868
912
|
status.textContent = 'No devices found in config.'
|
|
@@ -1000,7 +1044,12 @@ export function renderDeviceList(list: any[]): void {
|
|
|
1000
1044
|
copyBtn.classList.remove('success')
|
|
1001
1045
|
}, 1200)
|
|
1002
1046
|
} catch (e) {
|
|
1003
|
-
|
|
1047
|
+
copyBtn.textContent = 'Failed'
|
|
1048
|
+
copyBtn.classList.add('error')
|
|
1049
|
+
setTimeout(() => {
|
|
1050
|
+
copyBtn.textContent = 'Copy ID'
|
|
1051
|
+
copyBtn.classList.remove('error')
|
|
1052
|
+
}, 1200)
|
|
1004
1053
|
}
|
|
1005
1054
|
})
|
|
1006
1055
|
|
|
@@ -1023,4 +1072,6 @@ export function renderDeviceList(list: any[]): void {
|
|
|
1023
1072
|
li.appendChild(buttons)
|
|
1024
1073
|
ul.appendChild(li)
|
|
1025
1074
|
}
|
|
1075
|
+
|
|
1076
|
+
// No return value needed for void function
|
|
1026
1077
|
}
|
package/src/platform.ts
CHANGED
|
@@ -97,12 +97,46 @@ export const DEVICE_MATTER_CLUSTERS: Record<string, any> = {
|
|
|
97
97
|
}, // Switch in HAP, RobotVacuumCleaner in Matter
|
|
98
98
|
curtain: {
|
|
99
99
|
windowCovering: {
|
|
100
|
-
|
|
100
|
+
currentPositionLiftPercent100ths: 0,
|
|
101
|
+
targetPositionLiftPercent100ths: 0,
|
|
102
|
+
operationalStatus: {
|
|
103
|
+
global: 0,
|
|
104
|
+
lift: 0,
|
|
105
|
+
tilt: 0,
|
|
106
|
+
},
|
|
107
|
+
endProductType: 0,
|
|
108
|
+
configStatus: {
|
|
109
|
+
operational: true,
|
|
110
|
+
onlineReserved: true,
|
|
111
|
+
liftMovementReversed: false,
|
|
112
|
+
liftPositionAware: true,
|
|
113
|
+
tiltPositionAware: false,
|
|
114
|
+
liftEncoderControlled: true,
|
|
115
|
+
tiltEncoderControlled: false,
|
|
116
|
+
},
|
|
101
117
|
},
|
|
102
118
|
}, // WindowCovering → WindowCovering (includes curtain3, rollershade variants via normalization)
|
|
103
119
|
blindtilt: {
|
|
104
120
|
windowCovering: {
|
|
105
|
-
|
|
121
|
+
currentPositionLiftPercent100ths: 0,
|
|
122
|
+
targetPositionLiftPercent100ths: 0,
|
|
123
|
+
currentPositionTiltPercent100ths: 0,
|
|
124
|
+
targetPositionTiltPercent100ths: 0,
|
|
125
|
+
operationalStatus: {
|
|
126
|
+
global: 0,
|
|
127
|
+
lift: 0,
|
|
128
|
+
tilt: 0,
|
|
129
|
+
},
|
|
130
|
+
endProductType: 8,
|
|
131
|
+
configStatus: {
|
|
132
|
+
operational: true,
|
|
133
|
+
onlineReserved: true,
|
|
134
|
+
liftMovementReversed: false,
|
|
135
|
+
liftPositionAware: true,
|
|
136
|
+
tiltPositionAware: true,
|
|
137
|
+
liftEncoderControlled: true,
|
|
138
|
+
tiltEncoderControlled: true,
|
|
139
|
+
},
|
|
106
140
|
},
|
|
107
141
|
}, // WindowCovering with tilt → WindowCovering
|
|
108
142
|
fan: {
|
|
@@ -1030,7 +1064,7 @@ export class SwitchBotHAPPlatform {
|
|
|
1030
1064
|
|
|
1031
1065
|
constructor(log: Logger, config: PlatformConfig, api?: API) {
|
|
1032
1066
|
this.log = log
|
|
1033
|
-
this.config = (config as any)
|
|
1067
|
+
this.config = { ...(config as any), logger: log }
|
|
1034
1068
|
this.api = api
|
|
1035
1069
|
this.accessories = new Map()
|
|
1036
1070
|
this.log.info('SwitchBot HAP platform initialized')
|
|
@@ -1044,15 +1078,6 @@ export class SwitchBotHAPPlatform {
|
|
|
1044
1078
|
this.log.debug('Failed to create shared SwitchBot client', e)
|
|
1045
1079
|
}
|
|
1046
1080
|
|
|
1047
|
-
// Create/shared SwitchBot client and attach to config so child devices reuse it.
|
|
1048
|
-
try {
|
|
1049
|
-
const client = new SwitchBotClient(this.config)
|
|
1050
|
-
void client.init()
|
|
1051
|
-
;(this.config as any)._client = client
|
|
1052
|
-
} catch (e) {
|
|
1053
|
-
this.log.debug('Failed to create shared SwitchBot client', e)
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
1081
|
// Wait for Homebridge to finish launching to create/register accessories
|
|
1057
1082
|
if (this.api && typeof (this.api as any).on === 'function') {
|
|
1058
1083
|
(this.api as any).on('didFinishLaunching', async () => {
|
|
@@ -1133,23 +1158,7 @@ export class SwitchBotHAPPlatform {
|
|
|
1133
1158
|
}
|
|
1134
1159
|
|
|
1135
1160
|
const type: string = normalizeTypeForMatter(d.type)
|
|
1136
|
-
const windowCoveringTypes = ['curtain', 'curtain3', 'rollershade', 'roller shade', 'blindtilt', 'blind tilt']
|
|
1137
|
-
// Patch: If a config override for matterType is present, update DEVICE_MATTER_CLUSTERS for this device
|
|
1138
|
-
if (windowCoveringTypes.includes(type.toLowerCase()) && typeof raw.matterType === 'number') {
|
|
1139
|
-
if (DEVICE_MATTER_CLUSTERS[type] && DEVICE_MATTER_CLUSTERS[type].windowCovering) {
|
|
1140
|
-
DEVICE_MATTER_CLUSTERS[type].windowCovering.type = raw.matterType
|
|
1141
|
-
this.log.info(`[Matter/Debug] Overriding DEVICE_MATTER_CLUSTERS.${type}.windowCovering.type to`, raw.matterType)
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
1161
|
const deviceOpts: any = { id: d.id, type, name: d.name, encryptionKey: d.encryptionKey, keyId: d.keyId }
|
|
1145
|
-
if (windowCoveringTypes.includes(type.toLowerCase())) {
|
|
1146
|
-
if (typeof raw.matterType === 'number') {
|
|
1147
|
-
deviceOpts.matterType = raw.matterType
|
|
1148
|
-
this.log.info(`[Matter/Debug] Passing matterType override for device ${d.name ?? d.id}: matterType=${raw.matterType}`)
|
|
1149
|
-
} else {
|
|
1150
|
-
this.log.info(`[Matter/Debug] No matterType override for device ${d.name ?? d.id}`)
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
1162
|
this.log.debug(`[Matter/Debug] Device options for ${d.name ?? d.id}:`, JSON.stringify(deviceOpts, null, 2))
|
|
1154
1163
|
|
|
1155
1164
|
const matterSupported = !!DEVICE_MATTER_SUPPORTED[(type || '').toLowerCase()]
|
|
@@ -1353,7 +1362,7 @@ export class SwitchBotMatterPlatform {
|
|
|
1353
1362
|
|
|
1354
1363
|
constructor(log: Logger, config: PlatformConfig, api?: API) {
|
|
1355
1364
|
this.log = log
|
|
1356
|
-
this.config = (config as any)
|
|
1365
|
+
this.config = { ...(config as any), logger: log }
|
|
1357
1366
|
this.api = api
|
|
1358
1367
|
this.accessories = new Map()
|
|
1359
1368
|
this.log.info('SwitchBot Matter platform initialized')
|
|
@@ -1416,7 +1425,7 @@ export class SwitchBotMatterPlatform {
|
|
|
1416
1425
|
const matterEnabled = matterAvailable || !!this.config.enableMatter
|
|
1417
1426
|
const useMatter = matterEnabled && matterSupported
|
|
1418
1427
|
try {
|
|
1419
|
-
const created = await createDevice({ id: d.id, type, name: d.name }, this.config, useMatter)
|
|
1428
|
+
const created = await createDevice({ id: d.id, type, name: d.name, log: this.log }, this.config, useMatter)
|
|
1420
1429
|
this.devices.push(created)
|
|
1421
1430
|
if (useMatter) {
|
|
1422
1431
|
this.log.info(`Prepared Matter accessory for ${d.id} (${type})${matterAvailable ? ' (auto-detected)' : ' (manually enabled)'}`)
|
|
@@ -1516,7 +1525,7 @@ export class SwitchBotMatterPlatform {
|
|
|
1516
1525
|
}
|
|
1517
1526
|
|
|
1518
1527
|
try {
|
|
1519
|
-
const created = await createDevice({ id: d.id, type, name: d.name }, this.config, true)
|
|
1528
|
+
const created = await createDevice({ id: d.id, type, name: d.name, log: this.log }, this.config, true)
|
|
1520
1529
|
const createdDesc = await created.createAccessory(this.api)
|
|
1521
1530
|
const uuid = matterApi.uuid.generate(`${d.id}`)
|
|
1522
1531
|
// Try to find existing restored accessory by deviceId
|
package/src/switchbotClient.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { SwitchBotPluginConfig } from './settings.js'
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
import { CharacteristicMissingError, SwitchbotAuthenticationError, SwitchbotOperationError } from './errors.js'
|
|
3
4
|
|
|
4
5
|
export interface ISwitchBotClient {
|
|
5
|
-
init()
|
|
6
|
-
getDevice(id: string)
|
|
7
|
-
getDevices()
|
|
8
|
-
setDeviceState(id: string, body: any)
|
|
9
|
-
destroy()
|
|
6
|
+
init: () => Promise<void>
|
|
7
|
+
getDevice: (id: string) => Promise<any>
|
|
8
|
+
getDevices: () => Promise<any[]>
|
|
9
|
+
setDeviceState: (id: string, body: any) => Promise<any>
|
|
10
|
+
destroy: () => Promise<void>
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -21,15 +22,24 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
21
22
|
private requestTimeout = 5000
|
|
22
23
|
private maxRetries = 2
|
|
23
24
|
private writeDebounceMs = 100
|
|
24
|
-
private logger:
|
|
25
|
-
private pendingWrites: Map<string, { timer: any
|
|
25
|
+
private logger: import('homebridge').Logger
|
|
26
|
+
private pendingWrites: Map<string, { timer: any, body: any, resolvers: Array<{ resolve: (v: any) => void, reject: (e: any) => void }> }> = new Map()
|
|
26
27
|
|
|
27
28
|
constructor(cfg: SwitchBotPluginConfig) {
|
|
28
29
|
this.cfg = cfg
|
|
29
|
-
this.logger = (cfg as any)?.logger
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
this.logger = (cfg as any)?.logger as import('homebridge').Logger
|
|
31
|
+
if (!this.logger) {
|
|
32
|
+
throw new Error('SwitchBotClient requires a logger (Homebridge logger) in config')
|
|
33
|
+
}
|
|
34
|
+
if (typeof (cfg as any)?.requestTimeout === 'number') {
|
|
35
|
+
this.requestTimeout = (cfg as any).requestTimeout
|
|
36
|
+
}
|
|
37
|
+
if (typeof (cfg as any)?.maxRetries === 'number') {
|
|
38
|
+
this.maxRetries = (cfg as any).maxRetries
|
|
39
|
+
}
|
|
40
|
+
if (typeof (cfg as any)?.writeDebounceMs === 'number') {
|
|
41
|
+
this.writeDebounceMs = (cfg as any).writeDebounceMs
|
|
42
|
+
}
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
async init(): Promise<void> {
|
|
@@ -40,12 +50,12 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
40
50
|
token: this.cfg.openApiToken,
|
|
41
51
|
secret: this.cfg.openApiSecret,
|
|
42
52
|
// Enable all built-in resilience features from node-switchbot v4
|
|
43
|
-
enableFallback: true,
|
|
44
|
-
enableRetry: true,
|
|
45
|
-
enableCircuitBreaker: true,
|
|
46
|
-
enableMetrics: true,
|
|
47
|
-
enableBLE: true,
|
|
48
|
-
scanDuration: 5000,
|
|
53
|
+
enableFallback: true, // Auto-fallback from BLE to API
|
|
54
|
+
enableRetry: true, // Retry with exponential backoff
|
|
55
|
+
enableCircuitBreaker: true, // Circuit breaker per connection type
|
|
56
|
+
enableMetrics: true, // Connection tracking and statistics
|
|
57
|
+
enableBLE: true, // Enable BLE scanning by default
|
|
58
|
+
scanDuration: 5000, // BLE scan duration in milliseconds
|
|
49
59
|
...(typeof (this.cfg as any)?.nodeClientConfig === 'object' && (this.cfg as any).nodeClientConfig),
|
|
50
60
|
})
|
|
51
61
|
this.logger?.info?.('SwitchBot client initialized with native resilience features')
|
|
@@ -65,7 +75,7 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
65
75
|
const controller = new AbortController()
|
|
66
76
|
const timer = setTimeout(() => controller.abort(), to)
|
|
67
77
|
try {
|
|
68
|
-
const fetchOpts =
|
|
78
|
+
const fetchOpts = { ...opts }
|
|
69
79
|
try {
|
|
70
80
|
Object.defineProperty(fetchOpts, 'signal', { value: controller.signal, enumerable: false, configurable: true })
|
|
71
81
|
} catch (_e) {
|
|
@@ -73,7 +83,9 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
73
83
|
}
|
|
74
84
|
const resp = await fetch(url, fetchOpts)
|
|
75
85
|
clearTimeout(timer)
|
|
76
|
-
if (resp.ok)
|
|
86
|
+
if (resp.ok) {
|
|
87
|
+
return resp
|
|
88
|
+
}
|
|
77
89
|
// server error / rate limit: retry
|
|
78
90
|
if (resp.status >= 500 || resp.status === 429) {
|
|
79
91
|
throw new Error(`retriable response: ${resp.status}`)
|
|
@@ -81,10 +93,12 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
81
93
|
return resp
|
|
82
94
|
} catch (err: any) {
|
|
83
95
|
clearTimeout(timer)
|
|
84
|
-
if (attempt > max)
|
|
96
|
+
if (attempt > max) {
|
|
97
|
+
throw err
|
|
98
|
+
}
|
|
85
99
|
// exponential backoff
|
|
86
|
-
const backoff = Math.min(100 *
|
|
87
|
-
await new Promise(
|
|
100
|
+
const backoff = Math.min(100 * 2 ** (attempt - 1), 2000)
|
|
101
|
+
await new Promise(r => setTimeout(r, backoff))
|
|
88
102
|
}
|
|
89
103
|
}
|
|
90
104
|
}
|
|
@@ -151,10 +165,12 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
151
165
|
return
|
|
152
166
|
}
|
|
153
167
|
|
|
154
|
-
const resolvers: Array<{ resolve: (v:any)=>void
|
|
168
|
+
const resolvers: Array<{ resolve: (v: any) => void, reject: (e: any) => void }> = [{ resolve, reject }]
|
|
155
169
|
const timer = setTimeout(async () => {
|
|
156
170
|
const entry = this.pendingWrites.get(id)
|
|
157
|
-
if (!entry)
|
|
171
|
+
if (!entry) {
|
|
172
|
+
return
|
|
173
|
+
}
|
|
158
174
|
this.pendingWrites.delete(id)
|
|
159
175
|
try {
|
|
160
176
|
const out = await this._doSetDeviceState(id, entry.body)
|
|
@@ -224,7 +240,7 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
224
240
|
const opts = {
|
|
225
241
|
method: 'POST',
|
|
226
242
|
headers: {
|
|
227
|
-
Authorization: this.cfg.openApiToken,
|
|
243
|
+
'Authorization': this.cfg.openApiToken,
|
|
228
244
|
'Content-Type': 'application/json',
|
|
229
245
|
},
|
|
230
246
|
body: JSON.stringify(body),
|
|
@@ -248,4 +264,3 @@ export class SwitchBotClient implements ISwitchBotClient {
|
|
|
248
264
|
this.client = null
|
|
249
265
|
}
|
|
250
266
|
}
|
|
251
|
-
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { describe,
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
2
3
|
import { SwitchBotHAPPlatform, SwitchBotMatterPlatform } from '../src/platform'
|
|
3
4
|
|
|
4
5
|
const log = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }
|
|
@@ -8,7 +9,7 @@ describe('accessory restore', () => {
|
|
|
8
9
|
const registered: any[] = []
|
|
9
10
|
const hap = { uuid: { generate: (s: string) => `uuid-${s}` }, Service: {}, Characteristic: {} }
|
|
10
11
|
|
|
11
|
-
function PlatformAccessory(name: string, uuid: string) {
|
|
12
|
+
function PlatformAccessory(this: any, name: string, uuid: string) {
|
|
12
13
|
this.displayName = name
|
|
13
14
|
this.UUID = uuid
|
|
14
15
|
this.context = {}
|
|
@@ -20,10 +21,10 @@ describe('accessory restore', () => {
|
|
|
20
21
|
hap,
|
|
21
22
|
platformAccessory: PlatformAccessory as any,
|
|
22
23
|
registerPlatformAccessories: (_p: string, _n: string, accs: any[]) => registered.push(...accs),
|
|
23
|
-
on: (
|
|
24
|
+
on: () => {},
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
const platform = new SwitchBotHAPPlatform(log as any, { devices: [{ id: 'dev1', type: 'light', name: 'NewName' }] }, api)
|
|
27
|
+
const platform = new SwitchBotHAPPlatform(log as any, { platform: 'SwitchBot', devices: [{ id: 'dev1', type: 'light', name: 'NewName' }] }, api)
|
|
27
28
|
|
|
28
29
|
// Simulate restored accessory with different uuid but correct deviceId in context
|
|
29
30
|
const restored = new (api.platformAccessory)('OldName', 'old-uuid')
|
|
@@ -36,7 +37,7 @@ describe('accessory restore', () => {
|
|
|
36
37
|
// No new registration for this device because restored accessory was reused
|
|
37
38
|
expect(registered.length).toBe(0)
|
|
38
39
|
// The restored accessory should remain in the platform's accessory map
|
|
39
|
-
const keys =
|
|
40
|
+
const keys = [...(platform as any).accessories.keys()]
|
|
40
41
|
expect(keys).toContain('old-uuid')
|
|
41
42
|
const acc = (platform as any).accessories.get('old-uuid')
|
|
42
43
|
expect(acc.context.deviceId).toBe('dev1')
|
|
@@ -49,10 +50,10 @@ describe('accessory restore', () => {
|
|
|
49
50
|
matter,
|
|
50
51
|
isMatterAvailable: () => true,
|
|
51
52
|
isMatterEnabled: () => true,
|
|
52
|
-
on: (
|
|
53
|
+
on: () => {},
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
const platform = new SwitchBotMatterPlatform(log as any, { devices: [{ id: 'm1', type: 'light', name: 'M1' }] }, api)
|
|
56
|
+
const platform = new SwitchBotMatterPlatform(log as any, { platform: 'SwitchBot', devices: [{ id: 'm1', type: 'light', name: 'M1' }] }, api)
|
|
56
57
|
|
|
57
58
|
// Simulate restored Matter accessory with a different uuid but matching deviceId
|
|
58
59
|
const restored = { uuid: 'restored-uuid', displayName: 'OldM', context: { deviceId: 'm1', type: 'light' } }
|
|
@@ -65,7 +66,7 @@ describe('accessory restore', () => {
|
|
|
65
66
|
await (platform as any).registerMatterAccessories()
|
|
66
67
|
|
|
67
68
|
// The restored accessory should still be present and associated with deviceId
|
|
68
|
-
const keys =
|
|
69
|
+
const keys = [...(platform as any).accessories.keys()]
|
|
69
70
|
expect(keys).toContain(restored.uuid)
|
|
70
71
|
const acc = (platform as any).accessories.get(restored.uuid)
|
|
71
72
|
expect(acc.context.deviceId).toBe('m1')
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { describe,
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
2
3
|
import { createDevice } from '../src/deviceFactory'
|
|
3
4
|
|
|
4
5
|
const DEVICE_TYPES = [
|
|
@@ -18,7 +19,7 @@ const DEVICE_TYPES = [
|
|
|
18
19
|
describe('device mapping', () => {
|
|
19
20
|
it('maps known device types to device descriptors (HAP)', async () => {
|
|
20
21
|
for (const t of DEVICE_TYPES) {
|
|
21
|
-
const created = await createDevice({ id: `dev-${t}`, type: t, name: `Name-${t}` }, {}, false)
|
|
22
|
+
const created = await createDevice({ id: `dev-${t}`, type: t, name: `Name-${t}` }, { log: console, logger: console }, false)
|
|
22
23
|
expect(created).toHaveProperty('instance')
|
|
23
24
|
expect(created).toHaveProperty('createAccessory')
|
|
24
25
|
const acc = created.createAccessory?.(undefined)
|
|
@@ -31,7 +32,7 @@ describe('device mapping', () => {
|
|
|
31
32
|
})
|
|
32
33
|
|
|
33
34
|
it('returns matter protocol when requested', async () => {
|
|
34
|
-
const created = await createDevice({ id: 'm1', type: 'light', name: 'M1' }, { preferMatter: true, enableMatter: true }, true)
|
|
35
|
+
const created = await createDevice({ id: 'm1', type: 'light', name: 'M1' }, { preferMatter: true, enableMatter: true, log: console, logger: console }, true)
|
|
35
36
|
expect(created.protocol).toBe('matter')
|
|
36
37
|
})
|
|
37
38
|
})
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { describe,
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
2
3
|
import { createDevice } from '../src/deviceFactory'
|
|
3
4
|
|
|
4
5
|
describe('deviceFactory', () => {
|
|
5
6
|
it('creates HAP device descriptor for light', async () => {
|
|
6
|
-
const created = await createDevice({ id: 'dev1', type: 'light', name: 'L1' }, {}, false)
|
|
7
|
+
const created = await createDevice({ id: 'dev1', type: 'light', name: 'L1' }, { log: console, logger: console }, false)
|
|
7
8
|
expect(created).toHaveProperty('instance')
|
|
8
9
|
expect(created).toHaveProperty('createAccessory')
|
|
9
10
|
expect(created.protocol).toBe('hap')
|
|
@@ -12,7 +13,7 @@ describe('deviceFactory', () => {
|
|
|
12
13
|
})
|
|
13
14
|
|
|
14
15
|
it('creates Matter descriptor when useMatter=true', async () => {
|
|
15
|
-
const created = await createDevice({ id: 'dev2', type: 'light', name: 'L2' }, { preferMatter: true, enableMatter: true }, true)
|
|
16
|
+
const created = await createDevice({ id: 'dev2', type: 'light', name: 'L2' }, { preferMatter: true, enableMatter: true, log: console, logger: console }, true)
|
|
16
17
|
expect(created.protocol).toBe('matter')
|
|
17
18
|
})
|
|
18
19
|
})
|