@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.
Files changed (93) hide show
  1. package/config.schema.json +0 -11
  2. package/dist/deviceFactory.d.ts +3 -1
  3. package/dist/deviceFactory.d.ts.map +1 -1
  4. package/dist/deviceFactory.js +3 -18
  5. package/dist/deviceFactory.js.map +1 -1
  6. package/dist/devices/genericDevice.d.ts +1 -1
  7. package/dist/devices/genericDevice.d.ts.map +1 -1
  8. package/dist/devices/genericDevice.js +79 -159
  9. package/dist/devices/genericDevice.js.map +1 -1
  10. package/dist/homebridge-ui/public/index.html +107 -124
  11. package/dist/homebridge-ui/public/js/api.d.ts +1 -4
  12. package/dist/homebridge-ui/public/js/api.d.ts.map +1 -1
  13. package/dist/homebridge-ui/public/js/api.js +1 -7
  14. package/dist/homebridge-ui/public/js/api.js.map +1 -1
  15. package/dist/homebridge-ui/public/js/api.ts +1 -8
  16. package/dist/homebridge-ui/public/js/app.js +201 -90
  17. package/dist/homebridge-ui/public/js/app.js.map +2 -2
  18. package/dist/homebridge-ui/public/js/app.ts +5 -6
  19. package/dist/homebridge-ui/public/js/credentials.d.ts.map +1 -1
  20. package/dist/homebridge-ui/public/js/credentials.js +1 -1
  21. package/dist/homebridge-ui/public/js/credentials.js.map +1 -1
  22. package/dist/homebridge-ui/public/js/credentials.ts +7 -3
  23. package/dist/homebridge-ui/public/js/devices-delete.js +1 -1
  24. package/dist/homebridge-ui/public/js/devices-delete.js.map +1 -1
  25. package/dist/homebridge-ui/public/js/devices-delete.ts +1 -1
  26. package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -1
  27. package/dist/homebridge-ui/public/js/devices.js +0 -1
  28. package/dist/homebridge-ui/public/js/devices.js.map +1 -1
  29. package/dist/homebridge-ui/public/js/devices.ts +0 -2
  30. package/dist/homebridge-ui/public/js/discovery.d.ts.map +1 -1
  31. package/dist/homebridge-ui/public/js/discovery.js +6 -3
  32. package/dist/homebridge-ui/public/js/discovery.js.map +1 -1
  33. package/dist/homebridge-ui/public/js/discovery.ts +9 -3
  34. package/dist/homebridge-ui/public/js/logger.d.ts +1 -0
  35. package/dist/homebridge-ui/public/js/logger.d.ts.map +1 -1
  36. package/dist/homebridge-ui/public/js/logger.js +8 -3
  37. package/dist/homebridge-ui/public/js/logger.js.map +1 -1
  38. package/dist/homebridge-ui/public/js/logger.ts +12 -6
  39. package/dist/homebridge-ui/public/js/modals.d.ts +4 -0
  40. package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -1
  41. package/dist/homebridge-ui/public/js/modals.js +152 -59
  42. package/dist/homebridge-ui/public/js/modals.js.map +1 -1
  43. package/dist/homebridge-ui/public/js/modals.ts +187 -60
  44. package/dist/homebridge-ui/public/js/render.d.ts +3 -0
  45. package/dist/homebridge-ui/public/js/render.d.ts.map +1 -1
  46. package/dist/homebridge-ui/public/js/render.js +63 -24
  47. package/dist/homebridge-ui/public/js/render.js.map +1 -1
  48. package/dist/homebridge-ui/public/js/render.ts +102 -51
  49. package/dist/homebridge-ui/utils/logger.js +1 -1
  50. package/dist/homebridge-ui/utils/logger.js.map +1 -1
  51. package/dist/platform.d.ts.map +1 -1
  52. package/dist/platform.js +40 -32
  53. package/dist/platform.js.map +1 -1
  54. package/dist/switchbotClient.d.ts +5 -5
  55. package/dist/switchbotClient.d.ts.map +1 -1
  56. package/dist/switchbotClient.js +21 -12
  57. package/dist/switchbotClient.js.map +1 -1
  58. package/docs/variables/default.html +1 -1
  59. package/package.json +1 -1
  60. package/scripts/build-ui.js +4 -3
  61. package/scripts/generate-matter-maps.js +34 -17
  62. package/scripts/sync-device-types.mjs +4 -1
  63. package/src/deviceFactory.ts +3 -18
  64. package/src/devices/genericDevice.ts +77 -164
  65. package/src/homebridge-ui/public/index.html +107 -124
  66. package/src/homebridge-ui/public/js/api.ts +1 -8
  67. package/src/homebridge-ui/public/js/app.ts +5 -6
  68. package/src/homebridge-ui/public/js/credentials.ts +7 -3
  69. package/src/homebridge-ui/public/js/devices-delete.ts +1 -1
  70. package/src/homebridge-ui/public/js/devices.ts +0 -2
  71. package/src/homebridge-ui/public/js/discovery.ts +9 -3
  72. package/src/homebridge-ui/public/js/logger.ts +12 -6
  73. package/src/homebridge-ui/public/js/modals.ts +187 -60
  74. package/src/homebridge-ui/public/js/render.ts +102 -51
  75. package/src/homebridge-ui/utils/logger.ts +1 -1
  76. package/src/platform.ts +40 -31
  77. package/src/switchbotClient.ts +42 -27
  78. package/test/accessory-restore.spec.ts +9 -8
  79. package/test/device-mapping.spec.ts +4 -3
  80. package/test/deviceFactory.spec.ts +4 -3
  81. package/test/e2e/run-e2e.spec.ts +6 -10
  82. package/test/fan-swing.spec.ts +7 -6
  83. package/test/helpers/matter-harness.ts +18 -7
  84. package/test/lock-users.spec.ts +5 -5
  85. package/test/matter-childbridge.spec.ts +10 -7
  86. package/test/matter-descriptors.spec.ts +8 -8
  87. package/test/matter-device-state.spec.ts +52 -36
  88. package/test/matter-integration.spec.ts +53 -6
  89. package/test/platform.integration.spec.ts +7 -5
  90. package/test/switchbot-client-debounce.spec.ts +36 -40
  91. package/test/switchbot-client-openapi.spec.ts +22 -15
  92. package/test/switchbotClient.spec.ts +4 -3
  93. package/test/utils.spec.ts +2 -1
@@ -1,4 +1,9 @@
1
- import type { DEVICE_TYPES } from './constants.js'
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) { return null }
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) batteryHistory = JSON.parse(raw)
269
- } catch {}
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[batteryHistory.length - 1]
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) batteryHistory = batteryHistory.slice(-30)
278
- try { localStorage.setItem(batteryHistoryKey, JSON.stringify(batteryHistory)) } catch {}
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, h = 24, pad = 2
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', 'pm25', 'pm10', 'voc', 'co2', 'humidity', 'temperature',
428
- 'preset', 'mode', 'presetMode', 'direction', 'calibration', 'multiCommand',
429
- 'extendedInfo', 'segmentedControl', 'features', 'capabilities', 'state',
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(/\s/g, '')))
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(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()) + ':'
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
- let lastSeen = d.lastSeen || d.lastseen || d.updatedAt;
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) && Date.now() - last > 1000 * 60 * 60) { // 1 hour
557
- offline = true;
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) { nameContainer.appendChild(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) { detailsText += ` | Hub: ${d.hubDeviceId}` }
636
- if (d.address) { detailsText += ` | MAC: ${d.address}` }
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
- 'Both': 0,
796
- 'BLE': 1,
797
- 'OpenAPI': 2,
798
- 'API': 2,
799
- 'Unknown': 3,
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) { return }
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
- alert('Failed to copy')
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
  }
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable no-console */
2
- const PREFIX = '[SwitchBot UI]'
2
+ const PREFIX = '[SwitchBot UI/Server]'
3
3
 
4
4
  export const uiLog = {
5
5
  info: (message: string, ...parameters: any[]) => {
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
- type: 5, // GenericWindowCovering - supports lift feature (covers curtain3, rollershade variants via normalization)
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
- type: 8, // TiltBlindLiftAndTilt - supports tilt feature
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
@@ -1,12 +1,13 @@
1
1
  import type { SwitchBotPluginConfig } from './settings.js'
2
- import { SwitchbotOperationError, SwitchbotAuthenticationError, CharacteristicMissingError } from './errors.js'
2
+
3
+ import { CharacteristicMissingError, SwitchbotAuthenticationError, SwitchbotOperationError } from './errors.js'
3
4
 
4
5
  export interface ISwitchBotClient {
5
- init(): Promise<void>
6
- getDevice(id: string): Promise<any>
7
- getDevices(): Promise<any[]>
8
- setDeviceState(id: string, body: any): Promise<any>
9
- destroy(): Promise<void>
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: any
25
- private pendingWrites: Map<string, { timer: any; body: any; resolvers: Array<{ resolve: (v:any)=>void; reject: (e:any)=>void }>; }> = new Map()
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 ?? console
30
- if (typeof (cfg as any)?.requestTimeout === 'number') this.requestTimeout = (cfg as any).requestTimeout
31
- if (typeof (cfg as any)?.maxRetries === 'number') this.maxRetries = (cfg as any).maxRetries
32
- if (typeof (cfg as any)?.writeDebounceMs === 'number') this.writeDebounceMs = (cfg as any).writeDebounceMs
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, // Auto-fallback from BLE to API
44
- enableRetry: true, // Retry with exponential backoff
45
- enableCircuitBreaker: true, // Circuit breaker per connection type
46
- enableMetrics: true, // Connection tracking and statistics
47
- enableBLE: true, // Enable BLE scanning by default
48
- scanDuration: 5000, // BLE scan duration in milliseconds
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 = Object.assign({}, opts)
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) return resp
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) throw err
96
+ if (attempt > max) {
97
+ throw err
98
+ }
85
99
  // exponential backoff
86
- const backoff = Math.min(100 * Math.pow(2, attempt - 1), 2000)
87
- await new Promise((r) => setTimeout(r, backoff))
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; reject: (e:any)=>void }> = [{ resolve, reject }]
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) return
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, it, expect } from 'vitest'
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: (_ev: string, cb: Function) => {},
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 = Array.from((platform as any).accessories.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: (_ev: string, cb: Function) => {},
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 = Array.from((platform as any).accessories.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, it, expect } from 'vitest'
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, it, expect } from 'vitest'
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
  })