@switchbot/homebridge-switchbot 5.0.0-beta.3 → 5.0.0-beta.30

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 (137) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/config.schema.json +42 -4
  3. package/dist/devices-hap/device.d.ts +1 -0
  4. package/dist/devices-hap/device.d.ts.map +1 -1
  5. package/dist/devices-hap/device.js +70 -30
  6. package/dist/devices-hap/device.js.map +1 -1
  7. package/dist/devices-matter/BaseMatterAccessory.d.ts +23 -0
  8. package/dist/devices-matter/BaseMatterAccessory.d.ts.map +1 -1
  9. package/dist/devices-matter/BaseMatterAccessory.js +167 -5
  10. package/dist/devices-matter/BaseMatterAccessory.js.map +1 -1
  11. package/dist/devices-matter/ColorLightAccessory.d.ts.map +1 -1
  12. package/dist/devices-matter/ColorLightAccessory.js +12 -12
  13. package/dist/devices-matter/ColorLightAccessory.js.map +1 -1
  14. package/dist/devices-matter/ColorTemperatureLightAccessory.d.ts.map +1 -1
  15. package/dist/devices-matter/ColorTemperatureLightAccessory.js +5 -7
  16. package/dist/devices-matter/ColorTemperatureLightAccessory.js.map +1 -1
  17. package/dist/devices-matter/DimmableLightAccessory.js +9 -9
  18. package/dist/devices-matter/DimmableLightAccessory.js.map +1 -1
  19. package/dist/devices-matter/ExtendedColorLightAccessory.d.ts.map +1 -1
  20. package/dist/devices-matter/ExtendedColorLightAccessory.js +14 -15
  21. package/dist/devices-matter/ExtendedColorLightAccessory.js.map +1 -1
  22. package/dist/devices-matter/OnOffLightAccessory.d.ts.map +1 -1
  23. package/dist/devices-matter/OnOffLightAccessory.js +8 -16
  24. package/dist/devices-matter/OnOffLightAccessory.js.map +1 -1
  25. package/dist/devices-matter/OnOffOutletAccessory.d.ts +2 -0
  26. package/dist/devices-matter/OnOffOutletAccessory.d.ts.map +1 -1
  27. package/dist/devices-matter/OnOffOutletAccessory.js +10 -7
  28. package/dist/devices-matter/OnOffOutletAccessory.js.map +1 -1
  29. package/dist/devices-matter/OnOffSwitchAccessory.js +2 -2
  30. package/dist/devices-matter/OnOffSwitchAccessory.js.map +1 -1
  31. package/dist/homebridge-ui/public/index.html +48 -1
  32. package/dist/homebridge-ui/server.js +35 -0
  33. package/dist/homebridge-ui/server.js.map +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +4 -5
  36. package/dist/index.js.map +1 -1
  37. package/dist/index.test.js +7 -2
  38. package/dist/index.test.js.map +1 -1
  39. package/dist/irdevice/irdevice.d.ts +11 -10
  40. package/dist/irdevice/irdevice.d.ts.map +1 -1
  41. package/dist/irdevice/irdevice.js +76 -35
  42. package/dist/irdevice/irdevice.js.map +1 -1
  43. package/dist/platform-hap.d.ts +11 -14
  44. package/dist/platform-hap.d.ts.map +1 -1
  45. package/dist/platform-hap.js +64 -64
  46. package/dist/platform-hap.js.map +1 -1
  47. package/dist/platform-matter.d.ts +85 -6
  48. package/dist/platform-matter.d.ts.map +1 -1
  49. package/dist/platform-matter.js +1736 -84
  50. package/dist/platform-matter.js.map +1 -1
  51. package/dist/settings.d.ts +9 -0
  52. package/dist/settings.d.ts.map +1 -1
  53. package/dist/settings.js.map +1 -1
  54. package/dist/test/devices-matter/baseMatterAccessory.test.d.ts +2 -0
  55. package/dist/test/devices-matter/baseMatterAccessory.test.d.ts.map +1 -0
  56. package/dist/test/devices-matter/baseMatterAccessory.test.js +71 -0
  57. package/dist/test/devices-matter/baseMatterAccessory.test.js.map +1 -0
  58. package/dist/test/helpers/platform-fixtures.d.ts +9 -0
  59. package/dist/test/helpers/platform-fixtures.d.ts.map +1 -0
  60. package/dist/test/helpers/platform-fixtures.js +30 -0
  61. package/dist/test/helpers/platform-fixtures.js.map +1 -0
  62. package/dist/test/platform-matter.additional.test.d.ts +2 -0
  63. package/dist/test/platform-matter.additional.test.d.ts.map +1 -0
  64. package/dist/test/platform-matter.additional.test.js +35 -0
  65. package/dist/test/platform-matter.additional.test.js.map +1 -0
  66. package/dist/test/platform-matter.bleparse.test.d.ts +2 -0
  67. package/dist/test/platform-matter.bleparse.test.d.ts.map +1 -0
  68. package/dist/test/platform-matter.bleparse.test.js +43 -0
  69. package/dist/test/platform-matter.bleparse.test.js.map +1 -0
  70. package/dist/test/platform-matter.cleanup.test.d.ts +2 -0
  71. package/dist/test/platform-matter.cleanup.test.d.ts.map +1 -0
  72. package/dist/test/platform-matter.cleanup.test.js +70 -0
  73. package/dist/test/platform-matter.cleanup.test.js.map +1 -0
  74. package/dist/test/platform-matter.keepstale.test.d.ts +2 -0
  75. package/dist/test/platform-matter.keepstale.test.d.ts.map +1 -0
  76. package/dist/test/platform-matter.keepstale.test.js +27 -0
  77. package/dist/test/platform-matter.keepstale.test.js.map +1 -0
  78. package/dist/test/platform-matter.mapping.test.d.ts +2 -0
  79. package/dist/test/platform-matter.mapping.test.d.ts.map +1 -0
  80. package/dist/test/platform-matter.mapping.test.js +43 -0
  81. package/dist/test/platform-matter.mapping.test.js.map +1 -0
  82. package/dist/test/platform-matter.openapi-mapping.test.d.ts +2 -0
  83. package/dist/test/platform-matter.openapi-mapping.test.d.ts.map +1 -0
  84. package/dist/test/platform-matter.openapi-mapping.test.js +84 -0
  85. package/dist/test/platform-matter.openapi-mapping.test.js.map +1 -0
  86. package/dist/test/platform-matter.test.d.ts +2 -0
  87. package/dist/test/platform-matter.test.d.ts.map +1 -0
  88. package/dist/test/platform-matter.test.js +117 -0
  89. package/dist/test/platform-matter.test.js.map +1 -0
  90. package/dist/test/platform-matter.unregister.test.d.ts +2 -0
  91. package/dist/test/platform-matter.unregister.test.d.ts.map +1 -0
  92. package/dist/test/platform-matter.unregister.test.js +30 -0
  93. package/dist/test/platform-matter.unregister.test.js.map +1 -0
  94. package/dist/utils.d.ts +127 -0
  95. package/dist/utils.d.ts.map +1 -1
  96. package/dist/utils.js +380 -0
  97. package/dist/utils.js.map +1 -1
  98. package/dist/utils.test.d.ts +2 -0
  99. package/dist/utils.test.d.ts.map +1 -0
  100. package/dist/utils.test.js +95 -0
  101. package/dist/utils.test.js.map +1 -0
  102. package/dist/verifyconfig.test.js +2 -2
  103. package/dist/verifyconfig.test.js.map +1 -1
  104. package/docs/assets/main.js +2 -2
  105. package/docs/index.html +2 -2
  106. package/docs/variables/default.html +1 -1
  107. package/package.json +14 -14
  108. package/src/devices-hap/device.ts +68 -30
  109. package/src/devices-matter/BaseMatterAccessory.ts +168 -5
  110. package/src/devices-matter/ColorLightAccessory.ts +12 -12
  111. package/src/devices-matter/ColorTemperatureLightAccessory.ts +5 -7
  112. package/src/devices-matter/DimmableLightAccessory.ts +9 -9
  113. package/src/devices-matter/ExtendedColorLightAccessory.ts +14 -15
  114. package/src/devices-matter/OnOffLightAccessory.ts +8 -16
  115. package/src/devices-matter/OnOffOutletAccessory.ts +12 -7
  116. package/src/devices-matter/OnOffSwitchAccessory.ts +2 -2
  117. package/src/homebridge-ui/public/index.html +48 -1
  118. package/src/homebridge-ui/server.ts +37 -0
  119. package/src/index.test.ts +7 -2
  120. package/src/index.ts +4 -5
  121. package/src/irdevice/irdevice.ts +74 -35
  122. package/src/platform-hap.ts +68 -73
  123. package/src/platform-matter.ts +1772 -87
  124. package/src/settings.ts +13 -0
  125. package/src/test/devices-matter/baseMatterAccessory.test.ts +88 -0
  126. package/src/test/helpers/platform-fixtures.ts +33 -0
  127. package/src/test/platform-matter.additional.test.ts +44 -0
  128. package/src/test/platform-matter.bleparse.test.ts +47 -0
  129. package/src/test/platform-matter.cleanup.test.ts +86 -0
  130. package/src/test/platform-matter.keepstale.test.ts +37 -0
  131. package/src/test/platform-matter.mapping.test.ts +57 -0
  132. package/src/test/platform-matter.openapi-mapping.test.ts +109 -0
  133. package/src/test/platform-matter.test.ts +144 -0
  134. package/src/test/platform-matter.unregister.test.ts +39 -0
  135. package/src/utils.test.ts +96 -0
  136. package/src/utils.ts +391 -3
  137. package/src/verifyconfig.test.ts +11 -10
@@ -1,3 +1,5 @@
1
+ import type { Server } from 'node:http'
2
+
1
3
  import type {
2
4
  API,
3
5
  DynamicPlatformPlugin,
@@ -5,10 +7,12 @@ import type {
5
7
  MatterAccessory,
6
8
  SerializedMatterAccessory,
7
9
  } from 'homebridge'
10
+ import type { MqttClient } from 'mqtt'
8
11
  import type { bodyChange, device } from 'node-switchbot'
9
12
 
10
- import type { SwitchBotPlatformConfig } from './settings.js'
13
+ import type { devicesConfig, SwitchBotPlatformConfig } from './settings.js'
11
14
 
15
+ import asyncmqtt from 'async-mqtt'
12
16
  import { SwitchBotBLE, SwitchBotOpenAPI } from 'node-switchbot'
13
17
 
14
18
  import {
@@ -35,14 +39,8 @@ import {
35
39
  WindowBlindAccessory,
36
40
  } from './devices-matter/index.js'
37
41
  import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'
38
- import { cleanDeviceConfig, sleep } from './utils.js'
39
-
40
- /**
41
- * MatterPlatform
42
- * Demonstrates all available Matter device types in Homebridge
43
- *
44
- * Organized by official Matter Specification v1.4.1 categories
45
- */
42
+ import { ApiRequestTracker, cleanDeviceConfig, createPlatformLogger, formatDeviceIdAsMac, hs2rgb, makeBLESender, makeOpenAPISender, rgb2hs, sleep } from './utils.js'
43
+
46
44
  export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
47
45
  // Track restored HAP cached accessories (required for DynamicPlatformPlugin)
48
46
  // This is commented out here as this plugin does not have any HAP accessories
@@ -55,13 +53,76 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
55
53
  private switchBotBLE?: SwitchBotBLE
56
54
  // discovered devices cache
57
55
  private discoveredDevices: device[] = []
56
+ // Registry of created accessory instances keyed by normalized deviceId
57
+ private accessoryInstances: Map<string, any> = new Map()
58
+ // Refresh timers keyed by normalized deviceId
59
+ private refreshTimers: Map<string, NodeJS.Timeout> = new Map()
60
+ // Platform-level refresh timer for batch status updates
61
+ private platformRefreshTimer?: NodeJS.Timeout
62
+ // Device status cache from last refresh
63
+ private deviceStatusCache: Map<string, any> = new Map()
64
+ // Devices that have explicit per-device refresh timers (normalized deviceId)
65
+ private perDeviceRefreshSet: Set<string> = new Set()
66
+ // BLE event handlers keyed by device MAC (formatted)
67
+ private bleEventHandler: { [x: string]: (context: any) => void } = {}
68
+ // MQTT and Webhook properties (mirror HAP behavior so Matter platform can
69
+ // receive OpenAPI webhook events and/or MQTT-proxied webhook messages)
70
+ private mqttClient: MqttClient | null = null
71
+ private webhookEventListener: Server | null = null
72
+ private webhookEventHandler: { [x: string]: (context: any) => void } = {}
73
+ // Platform logging toggle (can be controlled via UI or config)
74
+ // Use same shape as HAP platform: string values like 'debug', 'debugMode', 'standard', or 'none'
75
+ private platformLogging?: string
76
+
77
+ // API request tracking (persistent across restarts)
78
+ private apiTracker?: ApiRequestTracker
79
+
80
+ // Platform-provided logging helpers (attached in constructor)
81
+ infoLog!: (...args: any[]) => void
82
+ successLog!: (...args: any[]) => void
83
+ debugSuccessLog!: (...args: any[]) => void
84
+ warnLog!: (...args: any[]) => void
85
+ debugWarnLog!: (...args: any[]) => void
86
+ errorLog!: (...args: any[]) => void
87
+ debugErrorLog!: (...args: any[]) => void
88
+ debugLog!: (...args: any[]) => void
89
+ loggingIsDebug!: () => Promise<boolean>
90
+ enablingPlatformLogging!: () => Promise<boolean>
58
91
 
59
92
  constructor(
60
93
  public readonly log: Logging,
61
94
  public readonly config: SwitchBotPlatformConfig,
62
95
  public readonly api: API,
63
96
  ) {
64
- this.log.debug('Finished initializing platform:', this.config.name)
97
+ // Determine platform logging preference (match HAP behaviour as closely as
98
+ // possible using config values. We default to 'standard' when unspecified.)
99
+ this.platformLogging = (this.config.options?.logging === 'debug' || this.config.options?.logging === 'standard' || this.config.options?.logging === 'none')
100
+ ? this.config.options.logging
101
+ : 'standard'
102
+
103
+ // Unconditional diagnostic using the raw Homebridge `log` so it always
104
+ // appears regardless of the platform logging helpers' gating logic.
105
+ try {
106
+ this.log.debug?.(`[SwitchBot Matter] effective platformLogging=${String(this.platformLogging)}`)
107
+ } catch (e: any) {
108
+ // swallow any logging errors — diagnostics are best-effort
109
+ }
110
+
111
+ // Attach platform-wide logging helpers from utils so Matter and device
112
+ // classes can use consistent logging methods (infoLog/debugLog/etc.)
113
+ const _pl = createPlatformLogger(async () => (this as any).platformLogging, this.log)
114
+ this.infoLog = _pl.infoLog
115
+ this.successLog = _pl.successLog
116
+ this.debugSuccessLog = _pl.debugSuccessLog
117
+ this.warnLog = _pl.warnLog
118
+ this.debugWarnLog = _pl.debugWarnLog
119
+ this.errorLog = _pl.errorLog
120
+ this.debugErrorLog = _pl.debugErrorLog
121
+ this.debugLog = _pl.debugLog
122
+ this.loggingIsDebug = _pl.loggingIsDebug
123
+ this.enablingPlatformLogging = _pl.enablingPlatformLogging
124
+
125
+ this.debugLog('Finished initializing platform:', this.config.name)
65
126
 
66
127
  // Normalize deviceConfig to remove UI-inserted defaults
67
128
  try {
@@ -74,12 +135,12 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
74
135
  }
75
136
  }
76
137
  } catch (e) {
77
- this.log.debug('Failed to clean deviceConfig: %s', e)
138
+ this.debugLog('Failed to clean deviceConfig: %s', e)
78
139
  }
79
140
 
80
141
  // Does the user have a version of Homebridge that is compatible with matter?
81
142
  if (!this.api.isMatterAvailable?.()) {
82
- this.log.warn('Matter is not available in this version of Homebridge. Please update Homebridge to use this plugin.')
143
+ this.warnLog('Matter is not available in this version of Homebridge. Please update Homebridge to use this plugin.')
83
144
  }
84
145
 
85
146
  // Check if the user has matter enabled, this means:
@@ -88,42 +149,880 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
88
149
  // In reality, only the below check is needed, but they are both included here for completeness
89
150
  // Remember to use a '?.' optional chaining operator in case the user is running an older version of Homebridge that does not have these APIs
90
151
  if (!this.api.isMatterEnabled?.()) {
91
- this.log.warn('Matter is not enabled in Homebridge. Please enable Matter in the Homebridge settings to use this plugin.')
152
+ this.warnLog('Matter is not enabled in Homebridge. Please enable Matter in the Homebridge settings to use this plugin.')
92
153
  return
93
154
  }
94
155
 
95
156
  // Register Matter accessories when Homebridge has finished launching
96
157
  this.api.on('didFinishLaunching', () => {
97
- this.log.debug('Executed didFinishLaunching callback')
158
+ this.debugLog('Executed didFinishLaunching callback')
159
+ // Log presence of credentials (do not log actual values)
160
+ try {
161
+ this.debugLog(`SwitchBot credentials present? token=${Boolean(this.config.credentials?.token)}, secret=${Boolean(this.config.credentials?.secret)}`)
162
+ } catch (e: any) {
163
+ this.debugLog('Failed to log credentials presence: %s', e?.message ?? e)
164
+ }
165
+
166
+ // Do not fall back to environment variables here — credentials must
167
+ // come from plugin config (Homebridge UI). If credentials appear
168
+ // missing we'll log that fact and continue; the UI should store token
169
+ // and secret in `config.credentials`.
98
170
  // Initialize SwitchBot API clients
99
171
  try {
100
172
  if (this.config.credentials?.token && this.config.credentials?.secret) {
101
173
  this.switchBotAPI = new SwitchBotOpenAPI(this.config.credentials.token, this.config.credentials.secret, this.config.options?.hostname)
102
174
  // forward basic logs
103
175
  if (!this.config.options?.disableLogsforOpenAPI && this.switchBotAPI?.on) {
104
- this.switchBotAPI.on('log', (l: any) => this.log.debug('[SwitchBot OpenAPI]', l.message))
176
+ this.switchBotAPI.on('log', (l: any) => this.debugLog('[SwitchBot OpenAPI]', l.message))
105
177
  }
106
178
  } else {
107
- this.log.debug('SwitchBot OpenAPI credentials not provided; cloud devices will be skipped')
179
+ this.debugLog('SwitchBot OpenAPI credentials not provided; cloud devices will be skipped')
108
180
  }
109
181
  } catch (e: any) {
110
- this.log.error('Failed to initialize SwitchBot OpenAPI:', e?.message ?? e)
182
+ this.errorLog('Failed to initialize SwitchBot OpenAPI:', e?.message ?? e)
183
+ }
184
+
185
+ // Initialize API request tracking
186
+ try {
187
+ this.apiTracker = new ApiRequestTracker(this.api, this.log, 'SwitchBot Matter')
188
+ this.apiTracker.startHourlyLogging()
189
+ } catch (e: any) {
190
+ this.errorLog('Failed to initialize API request tracking:', e?.message ?? e)
111
191
  }
112
192
 
113
193
  try {
114
194
  this.switchBotBLE = new SwitchBotBLE()
115
195
  if (!this.config.options?.disableLogsforBLE && this.switchBotBLE?.on) {
116
- this.switchBotBLE.on('log', (l: any) => this.log.debug('[SwitchBot BLE]', l.message))
196
+ this.switchBotBLE.on('log', (l: any) => this.debugLog('[SwitchBot BLE]', l.message))
117
197
  }
118
198
  } catch (e: any) {
119
- this.log.error('Failed to initialize SwitchBot BLE client:', e?.message ?? e)
199
+ this.errorLog('Failed to initialize SwitchBot BLE client:', e?.message ?? e)
200
+ }
201
+
202
+ // If BLE scanning is enabled, start scanning and route advertisements to registered handlers
203
+ if (this.config.options?.BLE && this.switchBotBLE) {
204
+ const ble = this.switchBotBLE
205
+ ;(async () => {
206
+ try {
207
+ await ble.startScan()
208
+ } catch (e: any) {
209
+ this.errorLog(`Failed to start BLE scanning: ${e?.message ?? e}`)
210
+ }
211
+
212
+ // route advertisements to our handlers
213
+ ble.onadvertisement = async (ad: any) => {
214
+ try {
215
+ const mac = (ad.address || '').toLowerCase()
216
+ const handler = this.bleEventHandler[mac]
217
+ if (handler) {
218
+ await handler(ad.serviceData)
219
+ }
220
+ } catch (e: any) {
221
+ this.errorLog(`Failed to handle BLE advertisement: ${e?.message ?? e}`)
222
+ }
223
+ }
224
+ })()
120
225
  }
121
226
 
122
- // perform device discovery from SwitchBot OpenAPI (if configured)
123
- void this.discoverDevices()
227
+ // Ensure we clean up any per-device timers and BLE handlers when Homebridge shuts down
228
+ this.api.on('shutdown', async () => {
229
+ try {
230
+ this.infoLog('Homebridge shutting down: clearing refresh timers and BLE handlers')
231
+
232
+ // Stop API tracking hourly logging
233
+ if (this.apiTracker) {
234
+ this.apiTracker.stopHourlyLogging()
235
+ }
236
+
237
+ // Clear all refresh timers
238
+ for (const [nid, t] of Array.from(this.refreshTimers.entries())) {
239
+ try {
240
+ clearInterval(t)
241
+ } catch (e: any) {
242
+ this.debugLog(`Failed to clear timer for ${nid}: ${e?.message ?? e}`)
243
+ }
244
+ this.refreshTimers.delete(nid)
245
+ }
246
+
247
+ // Clear platform-level refresh timer
248
+ try {
249
+ if (this.platformRefreshTimer) {
250
+ clearInterval(this.platformRefreshTimer)
251
+ this.platformRefreshTimer = undefined
252
+ }
253
+ } catch (e: any) {
254
+ this.debugLog(`Failed to clear platform refresh timer: ${e?.message ?? e}`)
255
+ }
256
+
257
+ // Clear accessory instances registry
258
+ try {
259
+ this.accessoryInstances.clear()
260
+ } catch (e: any) {
261
+ this.debugLog(`Failed to clear accessoryInstances: ${e?.message ?? e}`)
262
+ }
263
+
264
+ // Remove BLE handlers
265
+ try {
266
+ for (const k of Object.keys(this.bleEventHandler)) {
267
+ delete this.bleEventHandler[k]
268
+ }
269
+ } catch (e: any) {
270
+ this.debugLog(`Failed to clear bleEventHandler: ${e?.message ?? e}`)
271
+ }
272
+
273
+ // Stop BLE scanning if available
274
+ try {
275
+ if (this.switchBotBLE && typeof (this.switchBotBLE as any).stopScan === 'function') {
276
+ await (this.switchBotBLE as any).stopScan()
277
+ this.infoLog('Stopped BLE scanning')
278
+ }
279
+ } catch (e: any) {
280
+ this.debugLog(`Failed to stop BLE scanning: ${e?.message ?? e}`)
281
+ }
282
+ } catch (e: any) {
283
+ this.debugLog('Shutdown cleanup failed: %s', e?.message ?? e)
284
+ }
285
+ })
286
+
287
+ // perform device discovery from SwitchBot OpenAPI (if configured) and
288
+ // register Matter accessories after discovery completes. Previously we
289
+ // called discoverDevices() without awaiting it which caused registration
290
+ // to run before discovery finished and only example accessories were
291
+ // created. Use an async IIFE to sequentially await discovery then register.
292
+ ;(async () => {
293
+ try {
294
+ await this.discoverDevices()
295
+ } catch (e: any) {
296
+ this.debugLog('Device discovery failed during startup: %s', e?.message ?? e)
297
+ }
124
298
 
125
- this.registerMatterAccessories()
299
+ try {
300
+ await this.registerMatterAccessories()
301
+ } catch (e: any) {
302
+ this.errorLog('Failed to register Matter accessories: %s', e?.message ?? e)
303
+ }
304
+ })()
126
305
  })
306
+
307
+ try {
308
+ this.setupMqtt()
309
+ } catch (e: any) {
310
+ this.errorLog(`Setup MQTT, Error Message: ${e?.message ?? e}, Submit Bugs Here: ` + 'https://tinyurl.com/SwitchBotBug')
311
+ }
312
+ try {
313
+ this.setupwebhook()
314
+ } catch (e: any) {
315
+ this.errorLog(`Setup Webhook, Error Message: ${e?.message ?? e}, Submit Bugs Here: ` + 'https://tinyurl.com/SwitchBotBug')
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Normalize a deviceId for matching (uppercase alphanumerics only)
321
+ */
322
+ private normalizeDeviceId(deviceId: string) {
323
+ return (deviceId ?? '').toUpperCase().replace(/[^A-Z0-9]+/g, '')
324
+ }
325
+
326
+ /** Determine the platform-level batch interval in seconds */
327
+ private getPlatformBatchInterval(): number {
328
+ const opt = this.config.options
329
+ const val = opt?.matterBatchRefreshRate ?? opt?.refreshRate ?? 300
330
+ const n = Number(val)
331
+ return Number.isFinite(n) && n > 0 ? n : 300
332
+ }
333
+
334
+ /**
335
+ * Clear per-device resources: refresh timers, accessory instance registry, BLE handlers
336
+ */
337
+ private clearDeviceResources(deviceId?: string) {
338
+ if (!deviceId) {
339
+ return
340
+ }
341
+ try {
342
+ const nid = this.normalizeDeviceId(deviceId)
343
+ const existing = this.refreshTimers.get(nid)
344
+ if (existing) {
345
+ try {
346
+ clearInterval(existing)
347
+ } catch (e: any) {
348
+ this.debugLog(`Failed to clear refresh timer for ${deviceId}: ${e?.message ?? e}`)
349
+ }
350
+ this.refreshTimers.delete(nid)
351
+ }
352
+
353
+ try {
354
+ this.accessoryInstances.delete(nid)
355
+ } catch (e: any) {
356
+ this.debugLog(`Failed to delete accessory instance for ${deviceId}: ${e?.message ?? e}`)
357
+ }
358
+
359
+ try {
360
+ const mac = formatDeviceIdAsMac(deviceId).toLowerCase()
361
+ if (this.bleEventHandler[mac]) {
362
+ delete this.bleEventHandler[mac]
363
+ }
364
+ } catch (e: any) {
365
+ // formatting failed (not a MAC-like id) — ignore
366
+ this.debugLog(`clearDeviceResources: failed to remove BLE handler for ${deviceId}: ${e?.message ?? e}`)
367
+ }
368
+ } catch (e: any) {
369
+ this.debugLog(`clearDeviceResources top-level error for ${deviceId}: ${e?.message ?? e}`)
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Merge two arrays by deviceId. For each item in a1 (user-provided devices list),
375
+ * find matching item in a2 (discovered devices) and merge them with user overrides last.
376
+ */
377
+ private mergeByDeviceId(a1: { deviceId: string }[], a2: any[]) {
378
+ const allowConfigOnly = Boolean(this.config.options?.allowConfigOnlyDevices)
379
+ const result: any[] = []
380
+ for (const itm of (a1 || [])) {
381
+ const matchingItem = (a2 || []).find(item => this.normalizeDeviceId(item.deviceId) === this.normalizeDeviceId(itm.deviceId))
382
+ if (matchingItem) {
383
+ result.push(Object.assign({}, matchingItem, itm))
384
+ } else if (allowConfigOnly) {
385
+ // include config-only device as-is when explicitly allowed
386
+ result.push(Object.assign({}, itm))
387
+ }
388
+ // otherwise skip config-only entries
389
+ }
390
+ return result
391
+ }
392
+
393
+ /**
394
+ * Merge discovered devices with deviceConfig (per deviceType) and per-device overrides
395
+ * from `config.options.devices`, matching the behavior used in platform-hap.
396
+ */
397
+ private async mergeDiscoveredDevices(discovered: device[]): Promise<any[]> {
398
+ // If there's no device config or per-device config, return discovered as-is
399
+ if (!this.config.options?.devices && !this.config.options?.deviceConfig) {
400
+ return discovered
401
+ }
402
+
403
+ // Step 1: Assign missing deviceType from configDeviceType and merge deviceType-level configs
404
+ const devicesWithTypeConfig = await Promise.all(discovered.map(async (deviceObj) => {
405
+ if (!deviceObj.deviceType) {
406
+ deviceObj.deviceType = (deviceObj as any).configDeviceType !== undefined ? (deviceObj as any).configDeviceType : 'Unknown'
407
+ this.debugLog(`API missing deviceType for ${deviceObj.deviceId}, using configDeviceType: ${(deviceObj as any).configDeviceType}`)
408
+ }
409
+ const deviceTypeConfig = this.config.options?.deviceConfig?.[deviceObj.deviceType] || {}
410
+ return Object.assign({}, deviceObj, deviceTypeConfig)
411
+ }))
412
+
413
+ // Merge per-device overrides by matching deviceId
414
+ const merged = this.mergeByDeviceId(this.config.options?.devices ?? [], devicesWithTypeConfig ?? [])
415
+
416
+ // For any entries in merged (which are based on config.options.devices), ensure final per-device merges include deviceId-specific config
417
+ const final: any[] = []
418
+ for (const device of merged) {
419
+ // Find per-device config entry by deviceId (config.options.devices is an array)
420
+ const deviceIdConfig = (this.config.options?.devices || []).find((d: any) => this.normalizeDeviceId(d.deviceId) === this.normalizeDeviceId(device.deviceId)) || {}
421
+ const deviceWithConfig = Object.assign({}, device, deviceIdConfig)
422
+ final.push(deviceWithConfig)
423
+ }
424
+
425
+ // Also include any discovered devices that weren't present in the user devices list
426
+ const userDeviceIds = new Set((this.config.options?.devices || []).map((d: any) => this.normalizeDeviceId(d.deviceId)))
427
+ for (const d of devicesWithTypeConfig) {
428
+ if (!userDeviceIds.has(this.normalizeDeviceId(d.deviceId))) {
429
+ final.push(d)
430
+ }
431
+ }
432
+
433
+ return final
434
+ }
435
+
436
+ /**
437
+ * Select effective connection type for a device: prefer explicit device.connectionType,
438
+ * otherwise prefer BLE when platform BLE is enabled and device provides a BLE model/id.
439
+ */
440
+ private chooseConnectionType(deviceObj: any): 'BLE' | 'OpenAPI' {
441
+ if (deviceObj?.connectionType) {
442
+ return deviceObj.connectionType === 'BLE' ? 'BLE' : 'OpenAPI'
443
+ }
444
+ // If platform BLE is enabled and we have a bleModel or deviceId that formats to a MAC, prefer BLE
445
+ if (this.config.options?.BLE && (deviceObj?.bleModel || formatDeviceIdAsMac(deviceObj?.deviceId))) {
446
+ return 'BLE'
447
+ }
448
+ return 'OpenAPI'
449
+ }
450
+
451
+ /**
452
+ * Map a SwitchBot device object to a MatterAccessory using the device-specific
453
+ * Matter accessory classes in `src/devices-matter`.
454
+ */
455
+ private async createAccessoryFromDevice(dev: device & devicesConfig): Promise<MatterAccessory<Record<string, unknown>> | undefined> {
456
+ // Basic metadata
457
+ const displayName = dev.deviceName ?? dev.deviceId ?? 'SwitchBot Device'
458
+ const serial = dev.deviceId ?? 'unknown'
459
+ const manufacturer = 'SwitchBot'
460
+ const model = dev.model ?? dev.deviceType ?? 'SwitchBot'
461
+ const firmware = (dev as any).firmware ?? (dev as any).version ?? '0.0.0'
462
+
463
+ // Helper to build a default opts object consumed by the matter device classes
464
+ const baseOpts = {
465
+ uuid: this.api.matter.uuid.generate(serial),
466
+ displayName,
467
+ serialNumber: serial,
468
+ manufacturer,
469
+ model,
470
+ firmwareRevision: String(firmware),
471
+ hardwareRevision: '1.0.0',
472
+ deviceId: dev.deviceId,
473
+ // Inject handy platform-side helpers into the accessory `context` so Matter
474
+ // accessory classes can perform OpenAPI/BLE actions without reaching into
475
+ // the platform implementation directly.
476
+ context: {
477
+ deviceId: dev.deviceId,
478
+ // Expose the display name so Matter accessory classes can read it from context
479
+ name: displayName,
480
+ // Provide device-level logging override (if present) and platform logging flag
481
+ // so accessories can decide how verbose they should be.
482
+ deviceLogging: (dev as any)?.logging,
483
+ platformLogging: this.platformLogging,
484
+ },
485
+ }
486
+
487
+ // Build platform-side helpers using shared factories so they can be reused/tested
488
+ const sendOpenAPI = makeOpenAPISender(this.retryCommand.bind(this), dev, { maxRetries: this.config.options?.maxRetries ?? 1, delayBetweenRetries: this.config.options?.delayBetweenRetries ?? 1000 })
489
+ const sendBLE = makeBLESender(this.switchBotBLE, dev, { bleRetries: (this.config.options as any)?.bleRetries ?? 2, bleRetryDelay: (this.config.options as any)?.bleRetryDelay ?? 500 })
490
+
491
+ // Log that we're initializing this device so it's visible in startup logs
492
+ try {
493
+ this.infoLog(`Initializing Matter device: ${displayName} (type=${dev.deviceType ?? 'Unknown'}) id=${dev.deviceId}`)
494
+ } catch (e: any) {
495
+ // best-effort logging — swallow errors to avoid breaking initialization
496
+ this.debugLog('Failed to log initializing device:', e?.message ?? e)
497
+ }
498
+
499
+ const makeOnOffHandlers = (uuid: string, connectionType: 'BLE' | 'OpenAPI') => ({
500
+ onOff: {
501
+ on: async () => {
502
+ try {
503
+ if (connectionType === 'BLE' && this.switchBotBLE) {
504
+ await sendBLE('turnOn')
505
+ } else {
506
+ await sendOpenAPI('turnOn')
507
+ }
508
+ await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.OnOff, { onOff: true })
509
+ } catch (e: any) {
510
+ this.errorLog(`Failed to turn on device ${dev.deviceId}: ${e?.message ?? e}`)
511
+ }
512
+ },
513
+ off: async () => {
514
+ try {
515
+ if (connectionType === 'BLE' && this.switchBotBLE) {
516
+ await sendBLE('turnOff')
517
+ } else {
518
+ await sendOpenAPI('turnOff')
519
+ }
520
+ await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.OnOff, { onOff: false })
521
+ } catch (e: any) {
522
+ this.errorLog(`Failed to turn off device ${dev.deviceId}: ${e?.message ?? e}`)
523
+ }
524
+ },
525
+ },
526
+ })
527
+
528
+ // Mapping from SwitchBot deviceType -> constructor (expanded for parity with HAP)
529
+ const mapping: { [key: string]: any } = {
530
+ // Plugs / Outlets
531
+ 'Plug': OnOffOutletAccessory,
532
+ 'Plug Mini (US)': OnOffOutletAccessory,
533
+ 'Plug Mini (JP)': OnOffOutletAccessory,
534
+ 'Plug Mini': OnOffOutletAccessory,
535
+ 'WoPlug': OnOffOutletAccessory,
536
+
537
+ // Lighting
538
+ 'Color Bulb': ColorLightAccessory,
539
+ 'Color Bulb Mini': ColorLightAccessory,
540
+ 'Ceiling Light': ColorTemperatureLightAccessory,
541
+ 'Ceiling Light Pro': ColorTemperatureLightAccessory,
542
+ 'Strip Light': ExtendedColorLightAccessory,
543
+ 'Light Strip': ExtendedColorLightAccessory,
544
+ 'Light Strip Plus': ExtendedColorLightAccessory,
545
+ 'Strip Light Pro': ExtendedColorLightAccessory,
546
+ 'Dimmable Light': DimmableLightAccessory,
547
+
548
+ // Robot Vacuums
549
+ 'K10+': RoboticVacuumAccessory,
550
+ 'K10+ Pro': RoboticVacuumAccessory,
551
+ 'WoSweeper': RoboticVacuumAccessory,
552
+ 'WoSweeperMini': RoboticVacuumAccessory,
553
+ 'Robot Vacuum Cleaner S1': RoboticVacuumAccessory,
554
+ 'Robot Vacuum Cleaner S1 Plus': RoboticVacuumAccessory,
555
+ 'Robot Vacuum Cleaner S10': RoboticVacuumAccessory,
556
+ 'Robot Vacuum Cleaner S1 Pro': RoboticVacuumAccessory,
557
+ 'Robot Vacuum Cleaner S1 Mini': RoboticVacuumAccessory,
558
+
559
+ // Locks
560
+ 'Smart Lock': DoorLockAccessory,
561
+ 'Smart Lock Pro': DoorLockAccessory,
562
+
563
+ // Sensors
564
+ 'Motion Sensor': OccupancySensorAccessory,
565
+ 'Contact Sensor': ContactSensorAccessory,
566
+ 'Water Detector': LeakSensorAccessory,
567
+ 'Meter': TemperatureSensorAccessory,
568
+ 'MeterPlus': TemperatureSensorAccessory,
569
+ 'MeterPro': TemperatureSensorAccessory,
570
+ 'WoIOSensor': TemperatureSensorAccessory,
571
+ 'Air Purifier PM2.5': HumiditySensorAccessory,
572
+ 'Air Purifier Table PM2.5': HumiditySensorAccessory,
573
+ 'Air Purifier': HumiditySensorAccessory,
574
+ 'Air Purifier VOC': HumiditySensorAccessory,
575
+ 'Air Purifier Table VOC': HumiditySensorAccessory,
576
+
577
+ // Fans
578
+ 'Battery Circulator Fan': FanAccessory,
579
+
580
+ // Curtains / Blinds
581
+ 'Blind Tilt': VenetianBlindAccessory,
582
+ 'Curtain': WindowBlindAccessory,
583
+ 'Curtain2': WindowBlindAccessory,
584
+ 'Curtain3': WindowBlindAccessory,
585
+ 'Curtain 2': WindowBlindAccessory,
586
+ 'WoRollerShade': WindowBlindAccessory,
587
+ 'Roller Shade': WindowBlindAccessory,
588
+ 'Venetian Blind': VenetianBlindAccessory,
589
+
590
+ // Switches / Relays
591
+ 'Relay Switch 1': OnOffSwitchAccessory,
592
+ 'Relay Switch 1PM': OnOffSwitchAccessory,
593
+ 'Relay Switch 2': OnOffSwitchAccessory,
594
+ 'Relay Switch 3': OnOffSwitchAccessory,
595
+
596
+ // Misc / hubs / other
597
+ 'Hub 2': undefined,
598
+ 'Hub 3': undefined,
599
+ 'Hub Mini': undefined,
600
+ 'Bot': OnOffSwitchAccessory,
601
+ 'Smart Bot': OnOffSwitchAccessory,
602
+ 'Humidifier': HumiditySensorAccessory,
603
+ 'Humidifier2': HumiditySensorAccessory,
604
+ 'Thermostat': ThermostatAccessory,
605
+ 'Water Heater': ThermostatAccessory,
606
+ }
607
+
608
+ const Ctor = mapping[dev.deviceType ?? '']
609
+ if (!Ctor) {
610
+ this.debugLog(`No Matter mapping for deviceType='${dev.deviceType}', deviceId=${dev.deviceId}`)
611
+ return undefined
612
+ }
613
+
614
+ // Build opts and handlers tailored for basic capabilities
615
+ const uuid = baseOpts.uuid
616
+ const handlers: Record<string, any> = {}
617
+
618
+ // Choose connection type for this device (BLE vs OpenAPI)
619
+ const connectionType = this.chooseConnectionType(dev)
620
+
621
+ // On/Off common
622
+ handlers.onOff = makeOnOffHandlers(uuid, connectionType).onOff
623
+
624
+ // If this is a light, add brightness and color handlers
625
+ if (['Color Bulb', 'Ceiling Light', 'Ceiling Light Pro', 'Strip Light', 'Dimmable Light'].includes(dev.deviceType ?? '')) {
626
+ // levelControl
627
+ handlers.levelControl = {
628
+ moveToLevelWithOnOff: async (request: any) => {
629
+ try {
630
+ const level = request.level as number
631
+ const percent = Math.round((level / 254) * 100)
632
+ if (connectionType === 'BLE' && this.switchBotBLE) {
633
+ await sendBLE('setBrightness', percent)
634
+ } else {
635
+ await sendOpenAPI('setBrightness', `${percent}`)
636
+ }
637
+ await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.LevelControl, { currentLevel: level })
638
+ } catch (e: any) {
639
+ this.errorLog(`Failed to set brightness for ${dev.deviceId}: ${e?.message ?? e}`)
640
+ }
641
+ },
642
+ }
643
+
644
+ // colorControl
645
+ handlers.colorControl = {
646
+ moveToHueAndSaturationLogic: async (request: any) => {
647
+ try {
648
+ const hue = request.hue as number
649
+ const saturation = request.saturation as number
650
+ const [r, g, b] = hs2rgb(Math.round((hue / 254) * 360), Math.round((saturation / 254) * 100))
651
+ if (connectionType === 'BLE' && this.switchBotBLE) {
652
+ await sendBLE('setRGB', Number(request.level ?? 100), r, g, b)
653
+ } else {
654
+ await sendOpenAPI('setColor', `${r}:${g}:${b}`)
655
+ }
656
+ await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentHue: hue, currentSaturation: saturation })
657
+ } catch (e: any) {
658
+ this.errorLog(`Failed to set hue/sat for ${dev.deviceId}: ${e?.message ?? e}`)
659
+ }
660
+ },
661
+ moveToColorLogic: async (request: any) => {
662
+ try {
663
+ // MoveToColor gives colorX/colorY values; convert to approximate RGB by mapping to 0-255 scale
664
+ const colorX = request.colorX as number
665
+ const colorY = request.colorY as number
666
+ // Naive conversion: map X/Y into RGB via hue approximation (not colorimetrically accurate)
667
+ const hueApprox = Math.round((colorX / 65535) * 360)
668
+ const satApprox = Math.round((colorY / 65535) * 100)
669
+ const [r, g, b] = hs2rgb(hueApprox, satApprox)
670
+ if (connectionType === 'BLE' && this.switchBotBLE) {
671
+ await sendBLE('setRGB', Number(request.level ?? 100), r, g, b)
672
+ } else {
673
+ await sendOpenAPI('setColor', `${r}:${g}:${b}`)
674
+ }
675
+ await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentX: colorX, currentY: colorY })
676
+ } catch (e: any) {
677
+ this.errorLog(`Failed to set XY color for ${dev.deviceId}: ${e?.message ?? e}`)
678
+ }
679
+ },
680
+ }
681
+
682
+ // color temperature — map to kelvin and send setColorTemperature
683
+ handlers.colorTemperature = {
684
+ moveToColorTemperature: async (request: any) => {
685
+ try {
686
+ const kelvin = Math.round(1000000 / Number(request.colorTemperature))
687
+ if (connectionType === 'BLE' && this.switchBotBLE) {
688
+ await sendBLE('setColorTemperature', kelvin)
689
+ } else {
690
+ await sendOpenAPI('setColorTemperature', `${kelvin}`)
691
+ }
692
+ await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentX: request.colorX ?? 0, currentY: request.colorY ?? 0 })
693
+ } catch (e: any) {
694
+ this.errorLog(`Failed to set color temperature for ${dev.deviceId}: ${e?.message ?? e}`)
695
+ }
696
+ },
697
+ }
698
+ }
699
+
700
+ // Expose platform helpers to the accessory via context so accessory
701
+ // classes can call OpenAPI/BLE actions (sendOpenAPI/sendBLE) and know
702
+ // the effective connection type.
703
+ try {
704
+ /* Inject platform helpers (OpenAPI/BLE senders + logging helpers + connection type)
705
+ into the accessory context so Matter accessory classes can use them without
706
+ reaching into the platform implementation directly. */
707
+ ;(baseOpts as any).context = Object.assign({}, (baseOpts as any).context, {
708
+ sendOpenAPI,
709
+ sendBLE,
710
+ connectionType,
711
+ // Expose platform logging helpers so accessories can use consistent logging
712
+ infoLog: this.infoLog,
713
+ debugLog: this.debugLog,
714
+ warnLog: this.warnLog,
715
+ errorLog: this.errorLog,
716
+ successLog: this.successLog,
717
+ })
718
+ } catch (e: any) {
719
+ this.debugLog('Failed to attach platform helpers to baseOpts.context: %s', e?.message ?? e)
720
+ }
721
+
722
+ const opts = Object.assign({}, baseOpts, { handlers })
723
+
724
+ // Instantiate the device class and return its serialized accessory
725
+ const instance = new Ctor(this.api, this.log, opts)
726
+ // Save instance in registry so platform can call device-specific update methods if needed
727
+ try {
728
+ if (dev?.deviceId) {
729
+ this.accessoryInstances.set(this.normalizeDeviceId(dev.deviceId), instance)
730
+ }
731
+ } catch (e: any) {
732
+ this.debugLog('Failed to register accessory instance: %s', e?.message ?? e)
733
+ }
734
+ try {
735
+ this.infoLog(`Initialized Matter accessory: ${displayName} (type=${dev.deviceType ?? 'Unknown'}) id=${dev.deviceId}`)
736
+ } catch (e: any) {
737
+ this.debugLog('Failed to log initialized accessory:', e?.message ?? e)
738
+ }
739
+
740
+ // Register BLE->Matter push handler for this device's MAC (if BLE scanning is active)
741
+ try {
742
+ const mac = formatDeviceIdAsMac(dev.deviceId).toLowerCase()
743
+ // Handler receives advertisement/serviceData when BLE scan events arrive
744
+ this.bleEventHandler[mac] = async (serviceData?: any) => {
745
+ const uuidLocal = baseOpts.uuid
746
+
747
+ // First try model-specific / normalized parsing of BLE advertisement
748
+ try {
749
+ const parsed = this.parseAdvertisementForDevice(dev, serviceData)
750
+ try {
751
+ const _p = JSON.stringify(parsed)
752
+ this.debugLog(`BLE advertisement parsed for ${dev.deviceId}: ${_p}`)
753
+ } catch (e) {
754
+ this.debugLog(`BLE advertisement parsed for ${dev.deviceId}: [unstringifiable parse result]`)
755
+ }
756
+ if (parsed) {
757
+ // Power
758
+ if (parsed.power !== undefined) {
759
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.OnOff, { onOff: Boolean(parsed.power) })
760
+ }
761
+
762
+ // Brightness
763
+ if (parsed.brightness !== undefined) {
764
+ const rawLevel = Math.round((Number(parsed.brightness) / 100) * 254)
765
+ const level = Math.max(0, Math.min(254, rawLevel))
766
+ this.debugLog(`[BLE Brightness Debug] Device ${dev.deviceId}: rawBrightness=${parsed.brightness}, calculated=${rawLevel}, clamped=${level}`)
767
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.LevelControl, { currentLevel: level })
768
+ }
769
+
770
+ // Color
771
+ if (parsed.color !== undefined) {
772
+ const { r, g, b } = parsed.color
773
+ const [h, s] = rgb2hs(r, g, b)
774
+ const rawHue = Math.round((h / 360) * 254)
775
+ const rawSat = Math.round((s / 100) * 254)
776
+ const hue = Math.max(0, Math.min(254, rawHue))
777
+ const sat = Math.max(0, Math.min(254, rawSat))
778
+ this.debugLog(`[BLE Color Debug] Device ${dev.deviceId}: RGB=[${r},${g},${b}], HS=[${h},${s}], Matter=[${rawHue},${rawSat}], clamped=[${hue},${sat}]`)
779
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.ColorControl, { currentHue: hue, currentSaturation: sat })
780
+ }
781
+
782
+ // Battery -> powerSource cluster (common mapping)
783
+ if (parsed.battery !== undefined) {
784
+ // Skip battery updates for device types that don't support PowerSource cluster
785
+ const deviceType = String(dev?.deviceType ?? '')
786
+ const unsupportedTypes = ['Curtain', 'Curtain2', 'Curtain3', 'Curtain 2', 'Blind Tilt']
787
+
788
+ if (unsupportedTypes.includes(deviceType)) {
789
+ this.debugLog(`Device ${dev.deviceId} type ${deviceType} does not support PowerSource cluster, skipping BLE battery update`)
790
+ } else {
791
+ try {
792
+ const percentage = Number(parsed.battery)
793
+ const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)))
794
+ let batChargeLevel = 0
795
+ if (percentage < 20) {
796
+ batChargeLevel = 2
797
+ } else if (percentage < 40) {
798
+ batChargeLevel = 1
799
+ }
800
+ try {
801
+ await this.api.matter.updateAccessoryState(uuidLocal, 'powerSource', { batPercentRemaining, batChargeLevel })
802
+ } catch (updateError: any) {
803
+ // Silently skip if powerSource cluster doesn't exist on this device
804
+ const msg = String(updateError?.message ?? updateError)
805
+ if (!msg.includes('does not exist') && !msg.includes('not found')) {
806
+ throw updateError
807
+ }
808
+ }
809
+ } catch (e: any) {
810
+ this.debugLog(`Failed to update battery state for ${dev.deviceId}: ${e?.message ?? e}`)
811
+ }
812
+ }
813
+ }
814
+
815
+ // Temperature -> temperatureMeasurement
816
+ if (parsed.temperature !== undefined) {
817
+ try {
818
+ const c = Number(parsed.temperature)
819
+ const measured = Math.round(c * 100)
820
+ await this.api.matter.updateAccessoryState(uuidLocal, 'temperatureMeasurement', { measuredValue: measured })
821
+ } catch (e: any) {
822
+ this.debugLog(`Failed to update temperature for ${dev.deviceId}: ${e?.message ?? e}`)
823
+ }
824
+ }
825
+
826
+ // Humidity -> relativeHumidityMeasurement
827
+ if (parsed.humidity !== undefined) {
828
+ try {
829
+ const percent = Number(parsed.humidity)
830
+ const measured = Math.round(percent * 100)
831
+ await this.api.matter.updateAccessoryState(uuidLocal, 'relativeHumidityMeasurement', { measuredValue: measured })
832
+ } catch (e: any) {
833
+ this.debugLog(`Failed to update humidity for ${dev.deviceId}: ${e?.message ?? e}`)
834
+ }
835
+ }
836
+
837
+ // Contact / Leak -> BooleanState
838
+ if (parsed.contact !== undefined || parsed.leak !== undefined) {
839
+ try {
840
+ // Some devices report contact as true=open; ContactSensor expects inverted value
841
+ const isContactOpen = parsed.contact === undefined ? undefined : Boolean(parsed.contact)
842
+ const leakDetected = parsed.leak === undefined ? undefined : Boolean(parsed.leak)
843
+
844
+ if (isContactOpen !== undefined) {
845
+ // If this is a contact sensor device type, invert; otherwise set conservatively
846
+ if ((dev.deviceType || '').includes('Contact')) {
847
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: !isContactOpen })
848
+ } else {
849
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: isContactOpen })
850
+ }
851
+ }
852
+
853
+ if (leakDetected !== undefined) {
854
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: leakDetected })
855
+ }
856
+ } catch (e: any) {
857
+ this.debugLog(`Failed to update contact/leak for ${dev.deviceId}: ${e?.message ?? e}`)
858
+ }
859
+ }
860
+
861
+ // Motion -> occupancy
862
+ if (parsed.motion !== undefined) {
863
+ try {
864
+ await this.api.matter.updateAccessoryState(uuidLocal, 'occupancySensing', { occupancy: { occupied: Boolean(parsed.motion) } })
865
+ } catch (e: any) {
866
+ this.debugLog(`Failed to update occupancy for ${dev.deviceId}: ${e?.message ?? e}`)
867
+ }
868
+ }
869
+
870
+ // Lock state -> doorLock
871
+ if (parsed.lock !== undefined) {
872
+ try {
873
+ const s = String(parsed.lock).toLowerCase()
874
+ let lockState = 0
875
+ if (s === 'locked' || s === '1' || s === 'true') {
876
+ lockState = 1
877
+ } else if (s === 'unlocked' || s === '0' || s === 'false') {
878
+ lockState = 2
879
+ }
880
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.DoorLock, { lockState })
881
+ } catch (e: any) {
882
+ this.debugLog(`Failed to update lock for ${dev.deviceId}: ${e?.message ?? e}`)
883
+ }
884
+ }
885
+
886
+ // Position / Cover -> WindowCovering (convert open percent to closed*100)
887
+ if (parsed.position !== undefined) {
888
+ try {
889
+ const openPercent = Number(parsed.position)
890
+ const closedPercent = 100 - Math.max(0, Math.min(100, openPercent))
891
+ const value = Math.round(closedPercent * 100)
892
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value })
893
+ } catch (e: any) {
894
+ this.debugLog(`Failed to update cover position for ${dev.deviceId}: ${e?.message ?? e}`)
895
+ }
896
+ }
897
+
898
+ // Fan speed -> FanControl
899
+ if (parsed.fanSpeed !== undefined) {
900
+ try {
901
+ const percent = Number(parsed.fanSpeed)
902
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent })
903
+ } catch (e: any) {
904
+ this.debugLog(`Failed to update fan speed for ${dev.deviceId}: ${e?.message ?? e}`)
905
+ }
906
+ }
907
+
908
+ // Robot vacuum fields (battery already handled) - update run/operational/clean modes when present
909
+ if (parsed.rvcRunMode !== undefined) {
910
+ try {
911
+ await this.api.matter.updateAccessoryState(uuidLocal, 'rvcRunMode', { currentMode: Number(parsed.rvcRunMode) })
912
+ } catch (e: any) {
913
+ this.debugLog(`Failed to update rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`)
914
+ }
915
+ }
916
+
917
+ if (parsed.rvcOperationalState !== undefined) {
918
+ try {
919
+ await this.api.matter.updateAccessoryState(uuidLocal, 'rvcOperationalState', { operationalState: Number(parsed.rvcOperationalState) })
920
+ } catch (e: any) {
921
+ this.debugLog(`Failed to update rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`)
922
+ }
923
+ }
924
+
925
+ // If we parsed something from serviceData prefer it and return early
926
+ if (serviceData) {
927
+ return
928
+ }
929
+ }
930
+ } catch (e: any) {
931
+ this.debugLog(`BLE advertisement parsing failed for ${dev.deviceId}: ${e?.message ?? e}`)
932
+ }
933
+
934
+ // Fallback to OpenAPI getDeviceStatus when serviceData is not present or parsing failed
935
+ if (!this.switchBotAPI) {
936
+ return
937
+ }
938
+ try {
939
+ this.apiTracker?.track()
940
+ const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret)
941
+ const respAny: any = response
942
+ const body = respAny?.body ?? respAny
943
+ try {
944
+ const s = JSON.stringify(body)
945
+ this.debugLog(`OpenAPI getDeviceStatus for ${dev.deviceId} returned statusCode=${statusCode} body=${s}`)
946
+ } catch (e) {
947
+ this.debugLog(`OpenAPI getDeviceStatus for ${dev.deviceId} returned statusCode=${statusCode} (body not stringifiable)`)
948
+ }
949
+ if (!(statusCode === 100 || statusCode === 200)) {
950
+ return
951
+ }
952
+ const status = body?.status ?? body
953
+
954
+ // Use centralized mapper which prefers accessory instance update helpers
955
+ await this.applyStatusToAccessory(uuidLocal, dev, status)
956
+ } catch (e: any) {
957
+ this.debugLog(`BLE push handler failed for ${dev.deviceId}: ${e?.message ?? e}`)
958
+ }
959
+ }
960
+ } catch (e: any) {
961
+ this.debugLog(`Failed to register BLE handler for ${dev.deviceId}: ${e?.message ?? e}`)
962
+ }
963
+
964
+ // Schedule periodic OpenAPI refreshes for this device (if OpenAPI is configured)
965
+ try {
966
+ const nid = this.normalizeDeviceId(dev.deviceId)
967
+ // Clear any existing timer for this device
968
+ const existing = this.refreshTimers.get(nid)
969
+ if (existing) {
970
+ clearInterval(existing)
971
+ this.refreshTimers.delete(nid)
972
+ }
973
+
974
+ const platformInterval = this.getPlatformBatchInterval()
975
+ const hasDeviceInterval = typeof dev.refreshRate === 'number' && Number(dev.refreshRate) > 0
976
+ if (this.switchBotAPI && (hasDeviceInterval || platformInterval > 0)) {
977
+ // Immediate one-shot to populate initial state
978
+ ;(async () => {
979
+ try {
980
+ this.infoLog(`Performing initial OpenAPI refresh for ${dev.deviceId}`)
981
+ this.apiTracker?.track()
982
+ const { response, statusCode } = await this.switchBotAPI!.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret)
983
+ const respAny: any = response
984
+ const body = respAny?.body ?? respAny
985
+ try {
986
+ const s = JSON.stringify(body)
987
+ this.debugLog(`Initial OpenAPI refresh for ${dev.deviceId} returned statusCode=${statusCode} body=${s}`)
988
+ } catch (e) {
989
+ this.debugLog(`Initial OpenAPI refresh for ${dev.deviceId} returned statusCode=${statusCode} (body not stringifiable)`)
990
+ }
991
+ if (statusCode === 100 || statusCode === 200) {
992
+ const status = body?.status ?? body
993
+ await this.applyStatusToAccessory(uuid, dev, status)
994
+ this.infoLog(`Initial OpenAPI refresh succeeded for ${dev.deviceId}`)
995
+ } else {
996
+ this.warnLog(`Initial OpenAPI refresh returned unexpected statusCode=${statusCode} for ${dev.deviceId}`)
997
+ }
998
+ } catch (e: any) {
999
+ this.errorLog(`Initial OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`)
1000
+ }
1001
+ })()
1002
+
1003
+ if (hasDeviceInterval) {
1004
+ // Create a per-device timer and exclude it from batch
1005
+ const interval = Number(dev.refreshRate)
1006
+ this.perDeviceRefreshSet.add(nid)
1007
+ const timer = setInterval(async () => {
1008
+ try {
1009
+ await this.refreshSingleDeviceWithRetry(dev)
1010
+ } catch (e: any) {
1011
+ this.debugLog(`Per-device refresh failed for ${dev.deviceId}: ${e?.message ?? e}`)
1012
+ }
1013
+ }, interval * 1000)
1014
+ this.refreshTimers.set(nid, timer)
1015
+ this.infoLog(`Started per-device refresh timer for ${dev.deviceId} at ${interval}s`)
1016
+ } else {
1017
+ // Start platform-level batched refresh timer (only once)
1018
+ this.startPlatformRefreshTimer(platformInterval)
1019
+ }
1020
+ }
1021
+ } catch (e: any) {
1022
+ this.debugLog(`Failed to schedule refresh for ${dev.deviceId}: ${e?.message ?? e}`)
1023
+ }
1024
+
1025
+ return instance.toAccessory()
127
1026
  }
128
1027
 
129
1028
  /**
@@ -131,25 +1030,98 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
131
1030
  */
132
1031
  private async discoverDevices(): Promise<void> {
133
1032
  if (!this.switchBotAPI) {
134
- this.log.debug('SwitchBot OpenAPI not configured; skipping discovery')
1033
+ this.debugLog('SwitchBot OpenAPI not configured; skipping discovery')
135
1034
  return
136
1035
  }
137
1036
 
138
1037
  try {
1038
+ this.apiTracker?.track()
139
1039
  const { response, statusCode } = await this.switchBotAPI.getDevices()
140
- this.log.debug(`SwitchBot getDevices response status: ${statusCode}`)
1040
+ this.debugLog(`SwitchBot getDevices response status: ${statusCode}`)
141
1041
  if (statusCode === 100 || statusCode === 200) {
142
1042
  const deviceList = Array.isArray(response?.body?.deviceList) ? response.body.deviceList : []
143
1043
  this.discoveredDevices = deviceList
144
- this.log.info(`Discovered ${deviceList.length} SwitchBot device(s) from OpenAPI`)
1044
+ this.infoLog(`Discovered ${deviceList.length} SwitchBot device(s) from OpenAPI`)
145
1045
  for (const d of deviceList) {
146
- this.log.debug(` - ${d.deviceName} (${d.deviceType}) id=${d.deviceId}`)
1046
+ this.debugLog(` - ${d.deviceName} (${d.deviceType}) id=${d.deviceId}`)
147
1047
  }
148
1048
  } else {
149
- this.log.warn(`SwitchBot getDevices returned status ${statusCode}`)
1049
+ this.warnLog(`SwitchBot getDevices returned status ${statusCode}`)
150
1050
  }
151
1051
  } catch (e: any) {
152
- this.log.error('Failed to discover SwitchBot devices:', e?.message ?? e)
1052
+ this.errorLog('Failed to discover SwitchBot devices:', e?.message ?? e)
1053
+ }
1054
+ }
1055
+
1056
+ /**
1057
+ * Setup MQTT connection (if configured) and route incoming webhook messages
1058
+ * to registered webhook handlers. Mirrors behaviour in platform-hap.
1059
+ */
1060
+ async setupMqtt(): Promise<void> {
1061
+ if (this.config.options?.mqttURL) {
1062
+ try {
1063
+ const { connectAsync } = asyncmqtt
1064
+ this.mqttClient = await connectAsync(this.config.options?.mqttURL, this.config.options.mqttOptions || {})
1065
+ this.debugLog('MQTT connection has been established successfully.')
1066
+ this.mqttClient.on('error', async (e: Error) => {
1067
+ this.errorLog(`Failed to publish MQTT messages. ${e.message ?? e}`)
1068
+ })
1069
+ if (!this.config.options?.webhookURL) {
1070
+ // receive webhook events via MQTT
1071
+ this.infoLog(`Webhook is configured to be received through ${this.config.options.mqttURL}/homebridge-switchbot/webhook.`)
1072
+ this.mqttClient.subscribe('homebridge-switchbot/webhook/+')
1073
+ this.mqttClient.on('message', async (topic: string, message: any) => {
1074
+ try {
1075
+ this.debugLog(`Received Webhook via MQTT: ${topic}=${message}`)
1076
+ const context = JSON.parse(message.toString())
1077
+ this.webhookEventHandler[context.deviceMac]?.(context)
1078
+ } catch (e: any) {
1079
+ this.errorLog(`Failed to handle webhook event. Error: ${e.message ?? e}`)
1080
+ }
1081
+ })
1082
+ }
1083
+ } catch (e: any) {
1084
+ this.mqttClient = null
1085
+ this.errorLog(`Failed to establish MQTT connection. ${e.message ?? e}`)
1086
+ }
1087
+ }
1088
+ }
1089
+
1090
+ /**
1091
+ * Setup OpenAPI webhook (if webhookURL configured) and forward incoming
1092
+ * webhook events to MQTT (if configured) and local handlers.
1093
+ */
1094
+ async setupwebhook() {
1095
+ if (this.config.options?.webhookURL) {
1096
+ const url = this.config.options?.webhookURL
1097
+ try {
1098
+ this.switchBotAPI?.setupWebhook(url)
1099
+ this.infoLog(`Webhook configured for URL: ${url}`)
1100
+ // Listen for webhook events
1101
+ this.switchBotAPI?.on('webhookEvent', (body: any) => {
1102
+ try {
1103
+ this.infoLog(`Received webhook event for device: ${body.context.deviceMac}`)
1104
+ if (this.config.options?.mqttURL) {
1105
+ const mac = body.context.deviceMac?.toLowerCase().match(/[\s\S]{1,2}/g)?.join(':')
1106
+ const options = this.config.options?.mqttPubOptions || {}
1107
+ this.mqttClient?.publish(`homebridge-switchbot/webhook/${mac}`, `${JSON.stringify(body.context)}`, options)
1108
+ }
1109
+ this.webhookEventHandler[body.context.deviceMac]?.(body.context)
1110
+ } catch (e: any) {
1111
+ this.errorLog(`Failed to handle webhook event. Error: ${e.message ?? e}`)
1112
+ }
1113
+ })
1114
+ } catch (e: any) {
1115
+ this.errorLog(`Failed to setup webhook. Error: ${e.message ?? e}`)
1116
+ }
1117
+
1118
+ this.api.on('shutdown', async () => {
1119
+ try {
1120
+ this.switchBotAPI?.deleteWebhook(url)
1121
+ } catch (e: any) {
1122
+ this.errorLog(`Failed to delete webhook. Error: ${e.message ?? e}`)
1123
+ }
1124
+ })
153
1125
  }
154
1126
  }
155
1127
 
@@ -163,6 +1135,7 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
163
1135
  if (!this.switchBotAPI) {
164
1136
  throw new Error('SwitchBot OpenAPI not initialized')
165
1137
  }
1138
+ this.apiTracker?.track()
166
1139
  const { response, statusCode } = await this.switchBotAPI.controlDevice(
167
1140
  deviceObj.deviceId,
168
1141
  bodyChange.command,
@@ -173,7 +1146,7 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
173
1146
  )
174
1147
  return { response, statusCode }
175
1148
  } catch (e: any) {
176
- this.log.debug(`retryCommand error: ${e?.message ?? e}`)
1149
+ this.debugLog(`retryCommand error: ${e?.message ?? e}`)
177
1150
  }
178
1151
  retryCount++
179
1152
 
@@ -182,6 +1155,471 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
182
1155
  return { response: {}, statusCode: 500 }
183
1156
  }
184
1157
 
1158
+ /**
1159
+ * Parse BLE advertisement/serviceData into normalized fields for a given device.
1160
+ * Returns null when serviceData is falsy or parsing fails.
1161
+ */
1162
+ private parseAdvertisementForDevice(dev: device, serviceData?: any) {
1163
+ if (!serviceData) {
1164
+ return null
1165
+ }
1166
+ try {
1167
+ const sd = serviceData
1168
+ const result: any = {}
1169
+
1170
+ // Power/on state - supports multiple field names used by different models
1171
+ const power = sd.power ?? sd.on ?? sd.p
1172
+ if (power !== undefined) {
1173
+ result.power = (String(power).toLowerCase() === 'on' || Number(power) === 1)
1174
+ }
1175
+
1176
+ // Brightness (0-100)
1177
+ const brightness = sd.brightness ?? sd.b
1178
+ if (brightness !== undefined) {
1179
+ result.brightness = Number(brightness)
1180
+ }
1181
+
1182
+ // Color - could be 'r:g:b', '#rrggbb' or 'rrggbb'
1183
+ const color = sd.color ?? sd.rgb ?? sd.c
1184
+ if (color !== undefined) {
1185
+ let r = 0
1186
+ let g = 0
1187
+ let b = 0
1188
+ const c = String(color)
1189
+ if (c.includes(':')) {
1190
+ const parts = c.split(':').map(Number)
1191
+ ;[r, g, b] = parts
1192
+ } else if (c.includes(',')) {
1193
+ const parts = c.split(',').map(s => Number(s.trim()))
1194
+ ;[r, g, b] = parts
1195
+ } else if (c.includes(' ')) {
1196
+ const parts = c.split(' ').map(s => Number(s.trim()))
1197
+ ;[r, g, b] = parts
1198
+ } else if (c.startsWith('#')) {
1199
+ const hex = c.replace('#', '')
1200
+ r = Number.parseInt(hex.substring(0, 2), 16)
1201
+ g = Number.parseInt(hex.substring(2, 4), 16)
1202
+ b = Number.parseInt(hex.substring(4, 6), 16)
1203
+ } else if (/^[0-9a-f]{6}$/i.test(c)) {
1204
+ r = Number.parseInt(c.substring(0, 2), 16)
1205
+ g = Number.parseInt(c.substring(2, 4), 16)
1206
+ b = Number.parseInt(c.substring(4, 6), 16)
1207
+ }
1208
+ result.color = { r, g, b }
1209
+ }
1210
+
1211
+ // Battery (some devices use battery or batt)
1212
+ const battery = sd.battery ?? sd.batt
1213
+ if (battery !== undefined) {
1214
+ result.battery = Number(battery)
1215
+ }
1216
+
1217
+ // VOC / TVOC (some air quality devices report total volatile organic compounds)
1218
+ const voc = sd.voc ?? sd.tvoc
1219
+ if (voc !== undefined) {
1220
+ result.voc = Number(voc)
1221
+ }
1222
+
1223
+ // PM10 (some devices report PM10 alongside PM2.5)
1224
+ const pm10 = sd.pm10
1225
+ if (pm10 !== undefined) {
1226
+ result.pm10 = Number(pm10)
1227
+ }
1228
+
1229
+ // PM2.5 (some BLE adverts use pm25 / pm_2_5)
1230
+ const pm25 = sd.pm2_5 ?? sd.pm25 ?? sd.pm_2_5
1231
+ if (pm25 !== undefined) {
1232
+ result.pm25 = Number(pm25)
1233
+ }
1234
+
1235
+ // CO2 (carbon dioxide ppm)
1236
+ const co2 = sd.co2 ?? sd.co2ppm ?? sd.carbonDioxide
1237
+ if (co2 !== undefined) {
1238
+ result.co2 = Number(co2)
1239
+ }
1240
+
1241
+ // Temperature (C) and Humidity (%) — support common shorthand keys
1242
+ const temperature = sd.temperature ?? sd.temp ?? sd.t
1243
+ if (temperature !== undefined) {
1244
+ result.temperature = Number(temperature)
1245
+ }
1246
+
1247
+ const humidity = sd.humidity ?? sd.h ?? sd.humid
1248
+ if (humidity !== undefined) {
1249
+ result.humidity = Number(humidity)
1250
+ }
1251
+
1252
+ // Motion, Contact, Leak
1253
+ const motion = sd.motion ?? sd.m
1254
+ if (motion !== undefined) {
1255
+ result.motion = Boolean(motion)
1256
+ }
1257
+
1258
+ const contact = sd.contact ?? sd.open
1259
+ if (contact !== undefined) {
1260
+ result.contact = contact
1261
+ }
1262
+
1263
+ const leak = sd.leak ?? sd.water
1264
+ if (leak !== undefined) {
1265
+ result.leak = Boolean(leak)
1266
+ }
1267
+
1268
+ // Position / Cover / Curtain synonyms
1269
+ const position = sd.position ?? sd.percent ?? sd.slidePosition ?? sd.curtainPosition
1270
+ if (position !== undefined) {
1271
+ result.position = Number(position)
1272
+ }
1273
+
1274
+ // Fan speed/speed
1275
+ const fanSpeed = sd.fanSpeed ?? sd.speed
1276
+ if (fanSpeed !== undefined) {
1277
+ result.fanSpeed = Number(fanSpeed)
1278
+ }
1279
+
1280
+ // Lock state
1281
+ const lock = sd.lock
1282
+ if (lock !== undefined) {
1283
+ result.lock = lock
1284
+ }
1285
+
1286
+ // Robot vacuum fields
1287
+ const rvcRunMode = sd.rvcRunMode
1288
+ if (rvcRunMode !== undefined) {
1289
+ result.rvcRunMode = rvcRunMode
1290
+ }
1291
+ const rvcOperationalState = sd.rvcOperationalState
1292
+ if (rvcOperationalState !== undefined) {
1293
+ result.rvcOperationalState = rvcOperationalState
1294
+ }
1295
+
1296
+ return result
1297
+ } catch (e: any) {
1298
+ this.debugLog(`parseAdvertisementForDevice failed for ${dev.deviceId}: ${e?.message ?? e}`)
1299
+ return null
1300
+ }
1301
+ }
1302
+
1303
+ /**
1304
+ * Central helper to apply a SwitchBot status object to a Matter accessory.
1305
+ * Tries to call accessory instance update helpers when available, otherwise
1306
+ * falls back to calling api.matter.updateAccessoryState directly.
1307
+ */
1308
+ private async applyStatusToAccessory(uuidLocal: string, dev: device & devicesConfig, status: any) {
1309
+ if (!status) {
1310
+ return
1311
+ }
1312
+
1313
+ const instance = dev?.deviceId ? this.accessoryInstances.get(this.normalizeDeviceId(dev.deviceId)) : undefined
1314
+
1315
+ // Helper to safely call instance methods or fallback to api.matter.updateAccessoryState
1316
+ const safeUpdate = async (cluster: string, attributes: Record<string, unknown>, methodName?: string) => {
1317
+ try {
1318
+ if (instance && methodName && typeof instance[methodName] === 'function') {
1319
+ // prefer device-specific update helpers when available
1320
+ await instance[methodName](...(Object.values(attributes)))
1321
+ } else if (instance && typeof instance.updateState === 'function') {
1322
+ // some accessories expose updateState that accepts cluster and attributes
1323
+ await instance.updateState(cluster, attributes)
1324
+ } else {
1325
+ try {
1326
+ await this.api.matter.updateAccessoryState(uuidLocal, cluster, attributes)
1327
+ } catch (updateError: any) {
1328
+ // Silently ignore "does not exist" errors for clusters that aren't
1329
+ // supported by this device type (e.g., powerSource on WindowBlind).
1330
+ const msg = String(updateError?.message ?? updateError)
1331
+ if (msg.includes('does not exist') || msg.includes('not found')) {
1332
+ this.debugLog(`Cluster ${cluster} not available on ${dev.deviceId}, skipping update`)
1333
+ return
1334
+ }
1335
+ throw updateError
1336
+ }
1337
+ }
1338
+ } catch (e: any) {
1339
+ this.debugLog(`safeUpdate failed for ${dev.deviceId} cluster=${cluster}: ${e?.message ?? e}`)
1340
+ }
1341
+ }
1342
+
1343
+ try {
1344
+ // On/Off
1345
+ if (status?.power !== undefined) {
1346
+ const on = (String(status.power).toLowerCase() === 'on' || Number(status.power) === 1 || Boolean(status.power) === true)
1347
+ await safeUpdate(this.api.matter.clusterNames.OnOff, { onOff: on }, 'updateOnOffState')
1348
+ }
1349
+
1350
+ // Robot vacuum: some OpenAPI responses use 'runState' or similar textual
1351
+ // fields to indicate cleaning/mapping/idle. Map common textual values to
1352
+ // the numeric run mode values expected by the RoboticVacuumAccessory and
1353
+ // prefer calling accessory helpers when present.
1354
+ if (status?.runState !== undefined || status?.run_state !== undefined || status?.run !== undefined) {
1355
+ try {
1356
+ const raw = status?.runState ?? status?.run_state ?? status?.run
1357
+ let mode: number | undefined
1358
+ if (typeof raw === 'number') {
1359
+ mode = Number(raw)
1360
+ } else if (typeof raw === 'string') {
1361
+ const s = raw.toLowerCase()
1362
+ if (s.includes('clean')) {
1363
+ mode = 1 // Cleaning
1364
+ } else if (s.includes('map')) {
1365
+ mode = 2 // Mapping
1366
+ } else if (s.includes('idle') || s.includes('stop') || s.includes('dock') || s.includes('charge') || s.includes('docked')) {
1367
+ mode = 0 // Idle
1368
+ } else {
1369
+ const n = Number(raw)
1370
+ if (!Number.isNaN(n)) {
1371
+ mode = n
1372
+ }
1373
+ }
1374
+ }
1375
+
1376
+ if (mode !== undefined) {
1377
+ await safeUpdate('rvcRunMode', { currentMode: Number(mode) }, 'updateRunMode')
1378
+ }
1379
+ } catch (e: any) {
1380
+ this.debugLog(`Failed to apply runState for ${dev.deviceId}: ${e?.message ?? e}`)
1381
+ }
1382
+ }
1383
+
1384
+ // Brightness
1385
+ if (status?.brightness !== undefined) {
1386
+ const rawBrightness = Number(status.brightness)
1387
+ const clampedBrightness = Math.max(0, Math.min(100, rawBrightness))
1388
+
1389
+ // If instance has updateBrightness method, it expects percentage (0-100)
1390
+ // Otherwise, updateAccessoryState expects Matter-scaled value (0-254)
1391
+ if (instance && typeof instance.updateBrightness === 'function') {
1392
+ this.debugLog(`[Brightness Debug] Device ${dev.deviceId}: calling updateBrightness with percent=${clampedBrightness}`)
1393
+ await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: clampedBrightness }, 'updateBrightness')
1394
+ } else {
1395
+ const level = Math.round((clampedBrightness / 100) * 254)
1396
+ const clampedLevel = Math.max(0, Math.min(254, level))
1397
+ this.debugLog(`[Brightness Debug] Device ${dev.deviceId}: calling updateAccessoryState with rawBrightness=${rawBrightness}, level=${clampedLevel}`)
1398
+ await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: clampedLevel })
1399
+ }
1400
+ }
1401
+
1402
+ // Color
1403
+ if (status?.color !== undefined) {
1404
+ const color = String(status.color)
1405
+ let r = 0
1406
+ let g = 0
1407
+ let b = 0
1408
+ if (color.includes(':')) {
1409
+ const parts = color.split(':').map(Number)
1410
+ ;[r, g, b] = parts
1411
+ } else if (color.includes(',')) {
1412
+ const parts = color.split(',').map(s => Number(s.trim()))
1413
+ ;[r, g, b] = parts
1414
+ } else if (color.includes(' ')) {
1415
+ const parts = color.split(' ').map(s => Number(s.trim()))
1416
+ ;[r, g, b] = parts
1417
+ } else if (color.startsWith('#')) {
1418
+ const hex = color.replace('#', '')
1419
+ r = Number.parseInt(hex.substring(0, 2), 16)
1420
+ g = Number.parseInt(hex.substring(2, 4), 16)
1421
+ b = Number.parseInt(hex.substring(4, 6), 16)
1422
+ }
1423
+ const [h, s] = rgb2hs(r, g, b)
1424
+ const clampedH = Math.max(0, Math.min(360, h))
1425
+ const clampedS = Math.max(0, Math.min(100, s))
1426
+
1427
+ // If instance has updateHueSaturation method, it expects raw values (h: 0-360, s: 0-100)
1428
+ // Otherwise, updateAccessoryState expects Matter-scaled values (0-254)
1429
+ if (instance && typeof instance.updateHueSaturation === 'function') {
1430
+ this.debugLog(`[Color Debug] Device ${dev.deviceId}: calling updateHueSaturation with color="${color}", RGB=[${r},${g},${b}], HS=[${clampedH},${clampedS}]`)
1431
+ await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: clampedH, currentSaturation: clampedS }, 'updateHueSaturation')
1432
+ } else {
1433
+ const hue = Math.round((clampedH / 360) * 254)
1434
+ const sat = Math.round((clampedS / 100) * 254)
1435
+ const clampedHue = Math.max(0, Math.min(254, hue))
1436
+ const clampedSat = Math.max(0, Math.min(254, sat))
1437
+ this.debugLog(`[Color Debug] Device ${dev.deviceId}: calling updateAccessoryState with color="${color}", RGB=[${r},${g},${b}], HS=[${h},${s}], Matter=[${clampedHue},${clampedSat}]`)
1438
+ await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: clampedHue, currentSaturation: clampedSat })
1439
+ }
1440
+ }
1441
+
1442
+ // Battery/powerSource (support many possible field names)
1443
+ // Note: Some device types like WindowBlind don't support PowerSource cluster
1444
+ if (status?.battery !== undefined || status?.batt !== undefined || status?.batteryLevel !== undefined || status?.batteryPercentage !== undefined || status?.battery_level !== undefined) {
1445
+ // Skip battery updates for device types that don't support PowerSource cluster
1446
+ const deviceType = String(status?.deviceType ?? dev?.deviceType ?? '')
1447
+ const unsupportedTypes = ['Curtain', 'Curtain2', 'Curtain3', 'Curtain 2', 'Blind Tilt']
1448
+
1449
+ if (unsupportedTypes.includes(deviceType)) {
1450
+ this.debugLog(`Device ${dev.deviceId} type ${deviceType} does not support PowerSource cluster, skipping battery update`)
1451
+ } else {
1452
+ try {
1453
+ const percentage = Number(status?.battery ?? status?.batt ?? status?.batteryPercentage ?? status?.batteryLevel ?? status?.battery_level)
1454
+ const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)))
1455
+ let batChargeLevel = 0
1456
+ if (percentage < 20) {
1457
+ batChargeLevel = 2
1458
+ } else if (percentage < 40) {
1459
+ batChargeLevel = 1
1460
+ }
1461
+ const powerCluster = (this.api.matter?.clusterNames && (this.api.matter.clusterNames as any).PowerSource) ? (this.api.matter.clusterNames as any).PowerSource : 'powerSource'
1462
+ await safeUpdate(powerCluster, { batPercentRemaining, batChargeLevel }, 'updateBatteryPercentage')
1463
+ } catch (e: any) {
1464
+ this.debugLog(`Failed to apply battery status for ${dev.deviceId}: ${e?.message ?? e}`)
1465
+ }
1466
+ }
1467
+ }
1468
+
1469
+ // Temperature + thermostat
1470
+ if (status?.temperature !== undefined || status?.temp !== undefined) {
1471
+ try {
1472
+ const c = Number(status?.temperature ?? status?.temp)
1473
+ const measured = Math.round(c * 100)
1474
+ await safeUpdate('temperatureMeasurement', { measuredValue: measured }, 'updateTemperature')
1475
+ // Thermostat-specific mapping
1476
+ if (status?.targetTemp !== undefined || status?.targetTemperature !== undefined || status?.heatingSetpoint !== undefined) {
1477
+ const target = Number(status?.targetTemp ?? status?.targetTemperature ?? status?.heatingSetpoint)
1478
+ const val = Math.round(target * 100)
1479
+ await safeUpdate('thermostat', { occupiedHeatingSetpoint: val }, 'updateHeatingSetpoint')
1480
+ }
1481
+ } catch (e: any) {
1482
+ this.debugLog(`Failed to apply temperature for ${dev.deviceId}: ${e?.message ?? e}`)
1483
+ }
1484
+ }
1485
+
1486
+ // Humidity (support different keys)
1487
+ if (status?.humidity !== undefined || status?.h !== undefined || status?.humid !== undefined) {
1488
+ try {
1489
+ const percent = Number(status?.humidity ?? status?.h ?? status?.humid)
1490
+ const measured = Math.round(percent * 100)
1491
+ await safeUpdate('relativeHumidityMeasurement', { measuredValue: measured }, 'updateHumidity')
1492
+ } catch (e: any) {
1493
+ this.debugLog(`Failed to apply humidity for ${dev.deviceId}: ${e?.message ?? e}`)
1494
+ }
1495
+ }
1496
+
1497
+ // Contact / Leak -> BooleanState
1498
+ if (status?.contact !== undefined || status?.open !== undefined || status?.leak !== undefined || status?.water !== undefined) {
1499
+ try {
1500
+ const isContactOpen = status?.contact ?? status?.open
1501
+ if (isContactOpen !== undefined) {
1502
+ if ((dev.deviceType || '').includes('Contact')) {
1503
+ await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: !(String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState')
1504
+ } else {
1505
+ await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: (String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState')
1506
+ }
1507
+ }
1508
+ const leakDetected = status?.leak ?? status?.water
1509
+ if (leakDetected !== undefined) {
1510
+ await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: Boolean(leakDetected) }, 'updateLeakState')
1511
+ }
1512
+ } catch (e: any) {
1513
+ this.debugLog(`Failed to apply contact/leak for ${dev.deviceId}: ${e?.message ?? e}`)
1514
+ }
1515
+ }
1516
+
1517
+ // Motion -> occupancy
1518
+ if (status?.motion !== undefined || status?.m !== undefined) {
1519
+ try {
1520
+ const detected = Boolean(status?.motion ?? status?.m)
1521
+ await safeUpdate('occupancySensing', { occupancy: { occupied: detected } }, 'updateOccupancy')
1522
+ } catch (e: any) {
1523
+ this.debugLog(`Failed to apply motion for ${dev.deviceId}: ${e?.message ?? e}`)
1524
+ }
1525
+ }
1526
+
1527
+ // Lock state
1528
+ if (status?.lock !== undefined) {
1529
+ try {
1530
+ const s = String(status.lock).toLowerCase()
1531
+ let lockState = 0
1532
+ if (s === 'locked' || s === '1' || s === 'true') {
1533
+ lockState = 1
1534
+ } else if (s === 'unlocked' || s === '0' || s === 'false') {
1535
+ lockState = 2
1536
+ }
1537
+ await safeUpdate(this.api.matter.clusterNames.DoorLock, { lockState }, 'updateLockState')
1538
+ } catch (e: any) {
1539
+ this.debugLog(`Failed to apply lock for ${dev.deviceId}: ${e?.message ?? e}`)
1540
+ }
1541
+ }
1542
+
1543
+ // Cover position
1544
+ if (status?.position !== undefined || status?.percent !== undefined) {
1545
+ try {
1546
+ const openPercent = Number(status?.position ?? status?.percent)
1547
+ const closedPercent = 100 - Math.max(0, Math.min(100, openPercent))
1548
+ const value = Math.round(closedPercent * 100)
1549
+ await safeUpdate(this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value }, 'updateLiftPosition')
1550
+ } catch (e: any) {
1551
+ this.debugLog(`Failed to apply cover position for ${dev.deviceId}: ${e?.message ?? e}`)
1552
+ }
1553
+ }
1554
+
1555
+ // Fan
1556
+ if (status?.fanSpeed !== undefined || status?.speed !== undefined) {
1557
+ try {
1558
+ const percent = Number(status?.fanSpeed ?? status?.speed)
1559
+ await safeUpdate(this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent }, 'updateFanSpeed')
1560
+ } catch (e: any) {
1561
+ this.debugLog(`Failed to apply fan speed for ${dev.deviceId}: ${e?.message ?? e}`)
1562
+ }
1563
+ }
1564
+
1565
+ // Robot vacuum: run/operational/clean modes
1566
+ if (status?.rvcRunMode !== undefined) {
1567
+ try {
1568
+ await safeUpdate('rvcRunMode', { currentMode: Number(status.rvcRunMode) }, 'updateRunMode')
1569
+ } catch (e: any) {
1570
+ this.debugLog(`Failed to apply rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`)
1571
+ }
1572
+ }
1573
+ // CO2 (carbon dioxide) - support common synonyms
1574
+ if (status?.co2 !== undefined || status?.co2ppm !== undefined || status?.carbonDioxide !== undefined) {
1575
+ try {
1576
+ const val = Number(status?.co2 ?? status?.co2ppm ?? status?.carbonDioxide)
1577
+ await safeUpdate('carbonDioxide', { carbonDioxideLevel: val }, 'updateCO2')
1578
+ } catch (e: any) {
1579
+ this.debugLog(`Failed to apply CO2 for ${dev.deviceId}: ${e?.message ?? e}`)
1580
+ }
1581
+ }
1582
+
1583
+ // PM2.5 / particulate matter
1584
+ if (status?.pm2_5 !== undefined || status?.pm25 !== undefined || status?.pm_2_5 !== undefined) {
1585
+ try {
1586
+ const pm = Number(status?.pm2_5 ?? status?.pm25 ?? status?.pm_2_5)
1587
+ await safeUpdate('pm2_5', { pm25: pm }, 'updatePM25')
1588
+ } catch (e: any) {
1589
+ this.debugLog(`Failed to apply PM2.5 for ${dev.deviceId}: ${e?.message ?? e}`)
1590
+ }
1591
+ }
1592
+ // PM10 (some devices report pm10)
1593
+ if (status?.pm10 !== undefined) {
1594
+ try {
1595
+ const pm10 = Number(status?.pm10)
1596
+ await safeUpdate('pm10', { pm10 }, 'updatePM10')
1597
+ } catch (e: any) {
1598
+ this.debugLog(`Failed to apply PM10 for ${dev.deviceId}: ${e?.message ?? e}`)
1599
+ }
1600
+ }
1601
+
1602
+ // VOC / TVOC - volatile organic compounds
1603
+ if (status?.voc !== undefined || status?.tvoc !== undefined) {
1604
+ try {
1605
+ const val = Number(status?.voc ?? status?.tvoc)
1606
+ await safeUpdate('voc', { voc: val }, 'updateVOC')
1607
+ } catch (e: any) {
1608
+ this.debugLog(`Failed to apply VOC for ${dev.deviceId}: ${e?.message ?? e}`)
1609
+ }
1610
+ }
1611
+ if (status?.rvcOperationalState !== undefined) {
1612
+ try {
1613
+ await safeUpdate('rvcOperationalState', { operationalState: Number(status.rvcOperationalState) }, 'updateOperationalState')
1614
+ } catch (e: any) {
1615
+ this.debugLog(`Failed to apply rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`)
1616
+ }
1617
+ }
1618
+ } catch (e: any) {
1619
+ this.debugLog(`applyStatusToAccessory top-level failure for ${dev.deviceId}: ${e?.message ?? e}`)
1620
+ }
1621
+ }
1622
+
185
1623
  /**
186
1624
  * Required for DynamicPlatformPlugin
187
1625
  * Called when homebridge restores cached accessories from disk at startup
@@ -199,7 +1637,7 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
199
1637
  * any custom data you stored when the accessory was originally registered.
200
1638
  */
201
1639
  configureMatterAccessory(accessory: SerializedMatterAccessory) {
202
- this.log.debug('Loading cached Matter accessory:', accessory.displayName)
1640
+ this.debugLog('Loading cached Matter accessory:', accessory.displayName)
203
1641
  this.matterAccessories.set(accessory.uuid, accessory)
204
1642
  }
205
1643
 
@@ -207,26 +1645,150 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
207
1645
  * Register all Matter accessories
208
1646
  */
209
1647
  private async registerMatterAccessories() {
210
- this.log.info('═'.repeat(80))
211
- this.log.info('Homebridge Matter Plugin')
212
- this.log.info('═'.repeat(80))
1648
+ this.debugLog('═'.repeat(80))
1649
+ this.infoLog('Homebridge Matter Plugin')
1650
+ this.debugLog('═'.repeat(80))
213
1651
 
214
1652
  // Remove accessories that are disabled in config
215
1653
  await this.removeDisabledAccessories()
216
1654
 
217
- // Register devices by Matter specification sections
218
- await this.registerSection4Lighting()
219
- await this.registerSection5SmartPlugs()
220
- await this.registerSection6Switches()
221
- await this.registerSection7Sensors()
222
- await this.registerSection8Closure()
223
- await this.registerSection9HVAC()
224
- await this.registerSection12Robotic()
225
- await this.registerCustomDevices()
226
-
227
- this.log.info('═'.repeat(80))
228
- this.log.info('Finished registering Matter accessories')
229
- this.log.info('═'.repeat(80))
1655
+ // If we discovered real SwitchBot devices via OpenAPI, map and register them
1656
+ if (this.discoveredDevices && this.discoveredDevices.length > 0) {
1657
+ this.infoLog(`Registering ${this.discoveredDevices.length} discovered SwitchBot device(s) as Matter accessories`)
1658
+
1659
+ // Merge device config (deviceConfig per deviceType and per-device overrides) to match HAP behavior
1660
+ const devicesToProcess = await this.mergeDiscoveredDevices(this.discoveredDevices)
1661
+
1662
+ // By default, automatically remove previously-registered Matter
1663
+ // accessories whose deviceId is not present in the merged discovered
1664
+ // list. If the user explicitly sets `options.keepStaleAccessories` to
1665
+ // true, then we will keep previously-registered accessories (legacy
1666
+ // behavior).
1667
+ if ((this.config as any).options?.keepStaleAccessories) {
1668
+ this.debugLog('Keeping previously-registered stale accessories because options.keepStaleAccessories=true')
1669
+ } else {
1670
+ try {
1671
+ const desiredIds = new Set((devicesToProcess || []).map((d: any) => this.normalizeDeviceId(d.deviceId)))
1672
+ const toUnregister: Array<MatterAccessory<Record<string, unknown>>> = []
1673
+ for (const [uuid, acc] of Array.from(this.matterAccessories.entries())) {
1674
+ try {
1675
+ const deviceId = (acc as any)?.context?.deviceId
1676
+ if (!deviceId) {
1677
+ continue
1678
+ }
1679
+ const nid = this.normalizeDeviceId(deviceId)
1680
+ if (!desiredIds.has(nid)) {
1681
+ // Accessory exists but is no longer desired -> schedule for removal
1682
+ this.infoLog(`Removing previously-registered accessory for deviceId=${deviceId} (no longer discovered or configured)`)
1683
+ try {
1684
+ this.clearDeviceResources(deviceId)
1685
+ } catch (e: any) {
1686
+ this.debugLog(`Failed to clear resources for ${deviceId} before unregister: ${e?.message ?? e}`)
1687
+ }
1688
+ toUnregister.push(acc as unknown as MatterAccessory<Record<string, unknown>>)
1689
+ this.matterAccessories.delete(uuid)
1690
+ }
1691
+ } catch (e: any) {
1692
+ this.debugLog(`Error while checking existing accessory ${uuid}: ${e?.message ?? e}`)
1693
+ }
1694
+ }
1695
+
1696
+ if (toUnregister.length > 0) {
1697
+ try {
1698
+ await this.api.matter.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, toUnregister)
1699
+ } catch (e: any) {
1700
+ this.debugLog(`Failed to unregister accessories: ${e?.message ?? e}`)
1701
+ }
1702
+ }
1703
+ } catch (e: any) {
1704
+ this.debugLog(`Failed to remove stale accessories: ${e?.message ?? e}`)
1705
+ }
1706
+ }
1707
+
1708
+ // We'll separate discovered devices into two buckets:
1709
+ // - platformAccessories: accessories that will be hosted under the plugin's Matter bridge
1710
+ // - roboticAccessories: robot vacuum devices which require standalone commissioning behaviour
1711
+ const platformAccessories: Array<MatterAccessory<Record<string, unknown>>> = []
1712
+ const roboticAccessories: Array<MatterAccessory<Record<string, unknown>>> = []
1713
+
1714
+ // Known robot vacuum deviceType names (matches mapping in createAccessoryFromDevice)
1715
+ const robotTypes = new Set([
1716
+ 'K10+',
1717
+ 'K10+ Pro',
1718
+ 'WoSweeper',
1719
+ 'WoSweeperMini',
1720
+ 'Robot Vacuum Cleaner S1',
1721
+ 'Robot Vacuum Cleaner S1 Plus',
1722
+ 'Robot Vacuum Cleaner S10',
1723
+ 'Robot Vacuum Cleaner S1 Pro',
1724
+ 'Robot Vacuum Cleaner S1 Mini',
1725
+ ])
1726
+
1727
+ for (const dev of devicesToProcess) {
1728
+ try {
1729
+ const acc = await this.createAccessoryFromDevice(dev)
1730
+ if (!acc) {
1731
+ continue
1732
+ }
1733
+ if (robotTypes.has(dev.deviceType ?? '')) {
1734
+ roboticAccessories.push(acc)
1735
+ } else {
1736
+ platformAccessories.push(acc)
1737
+ }
1738
+ } catch (e: any) {
1739
+ this.errorLog(`Failed to create Matter accessory for ${dev.deviceId}: ${e?.message ?? e}`)
1740
+ }
1741
+ }
1742
+
1743
+ // Register platform-hosted accessories (most devices)
1744
+ if (platformAccessories.length > 0) {
1745
+ this.infoLog(`✓ Registered ${platformAccessories.length} discovered platform-hosted device(s)`)
1746
+ for (const acc of platformAccessories) {
1747
+ this.infoLog(` - ${acc.displayName}`)
1748
+ }
1749
+ await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, platformAccessories)
1750
+ }
1751
+
1752
+ // Register robotic accessories (robot vacuums) separately so they can be
1753
+ // commissioned in the way Apple Home expects (these devices often require
1754
+ // standalone commissioning flow). We still call registerPlatformAccessories
1755
+ // because the accessory implementations manage their commissioning behavior.
1756
+ if (roboticAccessories.length > 0) {
1757
+ this.infoLog(`✓ Registered ${roboticAccessories.length} discovered robot vacuum device(s)`)
1758
+ for (const acc of roboticAccessories) {
1759
+ this.infoLog(` - ${acc.displayName} (standalone for Apple Home compatibility)`)
1760
+ }
1761
+ await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, roboticAccessories)
1762
+ }
1763
+
1764
+ // Debug/info: how many discovered vs example accessories were registered.
1765
+ // Example accessories are disabled — we intentionally do NOT register them.
1766
+ const discoveredRegistered = platformAccessories.length + roboticAccessories.length
1767
+ const exampleRegistered = 0
1768
+ this.debugLog(`Discovered accessories registered: ${discoveredRegistered}; Example accessories registered: ${exampleRegistered}`)
1769
+
1770
+ // Dump registry state to help runtime debugging: which accessory instances
1771
+ // were created and which refresh timers are scheduled. This helps confirm
1772
+ // whether safeUpdate will prefer accessory helpers and whether periodic
1773
+ // refreshes exist for each device.
1774
+ try {
1775
+ const instanceKeys = Array.from(this.accessoryInstances.keys())
1776
+ this.debugLog(`Accessory instances registered (${instanceKeys.length}): ${JSON.stringify(instanceKeys)}`)
1777
+ const timerKeys = Array.from(this.refreshTimers.keys())
1778
+ this.debugLog(`Refresh timers scheduled (${timerKeys.length}): ${JSON.stringify(timerKeys)}`)
1779
+ } catch (e: any) {
1780
+ this.debugLog(`Failed to dump platform registries: ${e?.message ?? e}`)
1781
+ }
1782
+
1783
+ return
1784
+ }
1785
+
1786
+ // If no discovered devices are available, do not register example/demo accessories.
1787
+ this.infoLog('No discovered SwitchBot devices found; not registering example Matter accessories by default.')
1788
+
1789
+ this.debugLog('═'.repeat(80))
1790
+ this.debugLog('Finished registering Matter accessories')
1791
+ this.debugLog('═'.repeat(80))
230
1792
  }
231
1793
 
232
1794
  /**
@@ -261,7 +1823,16 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
261
1823
  if (enabled === false) {
262
1824
  const existingAccessory = this.matterAccessories.get(uuid)
263
1825
  if (existingAccessory) {
264
- this.log.info(`Removing accessory '${name}' (disabled in config)`)
1826
+ this.infoLog(`Removing accessory '${name}' (disabled in config)`)
1827
+ // Attempt to clear any per-device resources (timers, BLE handlers, instances)
1828
+ try {
1829
+ const deviceId = (existingAccessory as any)?.context?.deviceId
1830
+ if (deviceId) {
1831
+ this.clearDeviceResources(deviceId)
1832
+ }
1833
+ } catch (e: any) {
1834
+ this.debugLog(`Failed to clear resources for disabled accessory ${name}: ${e?.message ?? e}`)
1835
+ }
265
1836
  await this.api.matter.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory as unknown as MatterAccessory])
266
1837
  this.matterAccessories.delete(uuid)
267
1838
  }
@@ -273,9 +1844,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
273
1844
  * Section 4: Lighting Devices (Matter Spec § 4)
274
1845
  */
275
1846
  private async registerSection4Lighting() {
276
- this.log.info('═'.repeat(80))
277
- this.log.info('Section 4: Lighting Devices (Matter Spec § 4)')
278
- this.log.info('═'.repeat(80))
1847
+ this.debugLog('═'.repeat(80))
1848
+ this.infoLog('Section 4: Lighting Devices (Matter Spec § 4)')
1849
+ this.debugLog('═'.repeat(80))
279
1850
 
280
1851
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
281
1852
 
@@ -310,9 +1881,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
310
1881
  }
311
1882
 
312
1883
  if (accessories.length > 0) {
313
- this.log.info(`✓ Registered ${accessories.length} lighting device(s)`)
1884
+ this.infoLog(`✓ Registered ${accessories.length} lighting device(s)`)
314
1885
  for (const acc of accessories) {
315
- this.log.info(` - ${acc.displayName}`)
1886
+ this.infoLog(` - ${acc.displayName}`)
316
1887
  }
317
1888
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
318
1889
  }
@@ -322,9 +1893,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
322
1893
  * Section 5: Smart Plugs/Actuators (Matter Spec § 5)
323
1894
  */
324
1895
  private async registerSection5SmartPlugs() {
325
- this.log.info('═'.repeat(80))
326
- this.log.info('Section 5: Smart Plugs/Actuators (Matter Spec § 5)')
327
- this.log.info('═'.repeat(80))
1896
+ this.debugLog('═'.repeat(80))
1897
+ this.infoLog('Section 5: Smart Plugs/Actuators (Matter Spec § 5)')
1898
+ this.debugLog('═'.repeat(80))
328
1899
 
329
1900
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
330
1901
 
@@ -335,9 +1906,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
335
1906
  }
336
1907
 
337
1908
  if (accessories.length > 0) {
338
- this.log.info(`✓ Registered ${accessories.length} smart plug/actuator device(s)`)
1909
+ this.infoLog(`✓ Registered ${accessories.length} smart plug/actuator device(s)`)
339
1910
  for (const acc of accessories) {
340
- this.log.info(` - ${acc.displayName}`)
1911
+ this.infoLog(` - ${acc.displayName}`)
341
1912
  }
342
1913
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
343
1914
  }
@@ -347,9 +1918,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
347
1918
  * Section 6: Switches & Controllers (Matter Spec § 6)
348
1919
  */
349
1920
  private async registerSection6Switches() {
350
- this.log.info('═'.repeat(80))
351
- this.log.info('Section 6: Switches & Controllers (Matter Spec § 6)')
352
- this.log.info('═'.repeat(80))
1921
+ this.debugLog('═'.repeat(80))
1922
+ this.infoLog('Section 6: Switches & Controllers (Matter Spec § 6)')
1923
+ this.debugLog('═'.repeat(80))
353
1924
 
354
1925
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
355
1926
 
@@ -360,9 +1931,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
360
1931
  }
361
1932
 
362
1933
  if (accessories.length > 0) {
363
- this.log.info(`✓ Registered ${accessories.length} switch/controller device(s)`)
1934
+ this.infoLog(`✓ Registered ${accessories.length} switch/controller device(s)`)
364
1935
  for (const acc of accessories) {
365
- this.log.info(` - ${acc.displayName}`)
1936
+ this.infoLog(` - ${acc.displayName}`)
366
1937
  }
367
1938
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
368
1939
  }
@@ -372,9 +1943,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
372
1943
  * Section 7: Sensors (Matter Spec § 7)
373
1944
  */
374
1945
  private async registerSection7Sensors() {
375
- this.log.info('═'.repeat(80))
376
- this.log.info('Section 7: Sensors (Matter Spec § 7)')
377
- this.log.info('═'.repeat(80))
1946
+ this.debugLog('═'.repeat(80))
1947
+ this.infoLog('Section 7: Sensors (Matter Spec § 7)')
1948
+ this.debugLog('═'.repeat(80))
378
1949
 
379
1950
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
380
1951
 
@@ -421,9 +1992,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
421
1992
  }
422
1993
 
423
1994
  if (accessories.length > 0) {
424
- this.log.info(`✓ Registered ${accessories.length} sensor device(s)`)
1995
+ this.infoLog(`✓ Registered ${accessories.length} sensor device(s)`)
425
1996
  for (const acc of accessories) {
426
- this.log.info(` - ${acc.displayName}`)
1997
+ this.infoLog(` - ${acc.displayName}`)
427
1998
  }
428
1999
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
429
2000
  }
@@ -433,9 +2004,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
433
2004
  * Section 8: Closure Devices (Matter Spec § 8)
434
2005
  */
435
2006
  private async registerSection8Closure() {
436
- this.log.info('═'.repeat(80))
437
- this.log.info('Section 8: Closure Devices (Matter Spec § 8)')
438
- this.log.info('═'.repeat(80))
2007
+ this.debugLog('═'.repeat(80))
2008
+ this.infoLog('Section 8: Closure Devices (Matter Spec § 8)')
2009
+ this.debugLog('═'.repeat(80))
439
2010
 
440
2011
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
441
2012
 
@@ -458,9 +2029,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
458
2029
  }
459
2030
 
460
2031
  if (accessories.length > 0) {
461
- this.log.info(`✓ Registered ${accessories.length} closure device(s)`)
2032
+ this.infoLog(`✓ Registered ${accessories.length} closure device(s)`)
462
2033
  for (const acc of accessories) {
463
- this.log.info(` - ${acc.displayName}`)
2034
+ this.infoLog(` - ${acc.displayName}`)
464
2035
  }
465
2036
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
466
2037
  }
@@ -470,9 +2041,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
470
2041
  * Section 9: HVAC (Matter Spec § 9)
471
2042
  */
472
2043
  private async registerSection9HVAC() {
473
- this.log.info('═'.repeat(80))
474
- this.log.info('Section 9: HVAC (Matter Spec § 9)')
475
- this.log.info('═'.repeat(80))
2044
+ this.debugLog('═'.repeat(80))
2045
+ this.infoLog('Section 9: HVAC (Matter Spec § 9)')
2046
+ this.debugLog('═'.repeat(80))
476
2047
 
477
2048
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
478
2049
 
@@ -489,9 +2060,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
489
2060
  }
490
2061
 
491
2062
  if (accessories.length > 0) {
492
- this.log.info(`✓ Registered ${accessories.length} HVAC device(s)`)
2063
+ this.infoLog(`✓ Registered ${accessories.length} HVAC device(s)`)
493
2064
  for (const acc of accessories) {
494
- this.log.info(` - ${acc.displayName}`)
2065
+ this.infoLog(` - ${acc.displayName}`)
495
2066
  }
496
2067
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
497
2068
  }
@@ -504,9 +2075,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
504
2075
  * Use those codes to pair the vacuum as a separate bridge in your Home app.
505
2076
  */
506
2077
  private async registerSection12Robotic() {
507
- this.log.info('═'.repeat(80))
508
- this.log.info('Section 12: Robotic Devices (Matter Spec § 12)')
509
- this.log.info('═'.repeat(80))
2078
+ this.debugLog('═'.repeat(80))
2079
+ this.infoLog('Section 12: Robotic Devices (Matter Spec § 12)')
2080
+ this.debugLog('═'.repeat(80))
510
2081
 
511
2082
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
512
2083
 
@@ -517,9 +2088,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
517
2088
  }
518
2089
 
519
2090
  if (accessories.length > 0) {
520
- this.log.info(`✓ Registered ${accessories.length} robot vacuum device(s)`)
2091
+ this.infoLog(`✓ Registered ${accessories.length} robot vacuum device(s)`)
521
2092
  for (const acc of accessories) {
522
- this.log.info(` - ${acc.displayName} (standalone for Apple Home compatibility)`)
2093
+ this.infoLog(` - ${acc.displayName} (standalone for Apple Home compatibility)`)
523
2094
  }
524
2095
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
525
2096
  }
@@ -533,9 +2104,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
533
2104
  * like managing multiple logical components within a single device.
534
2105
  */
535
2106
  private async registerCustomDevices() {
536
- this.log.info('═'.repeat(80))
537
- this.log.info('Custom Devices')
538
- this.log.info('═'.repeat(80))
2107
+ this.debugLog('═'.repeat(80))
2108
+ this.infoLog('Custom Devices')
2109
+ this.debugLog('═'.repeat(80))
539
2110
 
540
2111
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
541
2112
 
@@ -546,11 +2117,125 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
546
2117
  }
547
2118
 
548
2119
  if (accessories.length > 0) {
549
- this.log.info(`✓ Registered ${accessories.length} custom device(s)`)
2120
+ this.infoLog(`✓ Registered ${accessories.length} custom device(s)`)
550
2121
  for (const acc of accessories) {
551
- this.log.info(` - ${acc.displayName}`)
2122
+ this.infoLog(` - ${acc.displayName}`)
552
2123
  }
553
2124
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
554
2125
  }
555
2126
  }
2127
+
2128
+ /**
2129
+ * Start platform-level refresh timer to batch all device status updates
2130
+ */
2131
+ private startPlatformRefreshTimer(refreshRateSec: number): void {
2132
+ // Only create timer once
2133
+ if (this.platformRefreshTimer) {
2134
+ return
2135
+ }
2136
+
2137
+ this.debugLog(`Starting platform-level refresh timer with interval ${refreshRateSec}s`)
2138
+ this.platformRefreshTimer = setInterval(async () => {
2139
+ await this.batchRefreshAllDevices()
2140
+ }, Number(refreshRateSec) * 1000)
2141
+ }
2142
+
2143
+ /**
2144
+ * Batch refresh all devices - still makes individual API calls but batches them together
2145
+ * Note: SwitchBot API doesn't support true batch status calls, but we can parallelize them
2146
+ */
2147
+ private async batchRefreshAllDevices(): Promise<void> {
2148
+ if (!this.switchBotAPI) {
2149
+ return
2150
+ }
2151
+
2152
+ this.debugLog('Performing batched periodic OpenAPI refresh for all devices')
2153
+
2154
+ // Build list from registered accessory instances (uuid) and discovered devices
2155
+ const devicesToRefresh: Array<{ uuid: string, dev: device }> = []
2156
+ try {
2157
+ for (const [nid, instance] of this.accessoryInstances.entries()) {
2158
+ const uuid = (instance as any)?.uuid as string | undefined
2159
+ if (!uuid) {
2160
+ continue
2161
+ }
2162
+ const dev = this.discoveredDevices.find(d => this.normalizeDeviceId(d.deviceId) === nid)
2163
+ if (dev && !this.perDeviceRefreshSet.has(nid)) {
2164
+ devicesToRefresh.push({ uuid, dev })
2165
+ }
2166
+ }
2167
+ } catch (e: any) {
2168
+ this.errorLog(`Failed to enumerate devices for batch refresh: ${e?.message ?? e}`)
2169
+ return
2170
+ }
2171
+
2172
+ if (devicesToRefresh.length === 0) {
2173
+ this.debugLog('No devices to refresh')
2174
+ return
2175
+ }
2176
+
2177
+ this.debugLog(`Refreshing ${devicesToRefresh.length} devices in parallel batch`)
2178
+
2179
+ const concurrency = Number(this.config.options?.matterBatchConcurrency ?? 5)
2180
+ await this.runWithConcurrency(devicesToRefresh, async ({ uuid, dev }) => {
2181
+ try {
2182
+ const status = await this.refreshSingleDeviceWithRetry(dev)
2183
+ if (status) {
2184
+ await this.applyStatusToAccessory(uuid, dev as any, status)
2185
+ }
2186
+ } catch (e: any) {
2187
+ this.errorLog(`Periodic OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`)
2188
+ }
2189
+ }, Number.isFinite(concurrency) && concurrency > 0 ? concurrency : 5)
2190
+ this.debugLog(`Batch refresh completed for ${devicesToRefresh.length} devices`)
2191
+ }
2192
+
2193
+ /** Refresh a single device with retry and backoff; returns status object if successful */
2194
+ private async refreshSingleDeviceWithRetry(dev: device, retries = 3, baseDelayMs = 500): Promise<any | null> {
2195
+ const deviceId = dev.deviceId
2196
+ let attempt = 0
2197
+ while (attempt <= retries) {
2198
+ try {
2199
+ this.apiTracker?.track()
2200
+ const { response, statusCode } = await this.switchBotAPI!.getDeviceStatus(deviceId, this.config.credentials?.token, this.config.credentials?.secret)
2201
+ const respAny: any = response
2202
+ const body = respAny?.body ?? respAny
2203
+ if (statusCode === 100 || statusCode === 200) {
2204
+ const status = body?.status ?? body
2205
+ this.deviceStatusCache.set(this.normalizeDeviceId(deviceId), { status, timestamp: Date.now() })
2206
+ this.debugLog(`OpenAPI refresh succeeded for ${deviceId} (attempt ${attempt + 1})`)
2207
+ return status
2208
+ }
2209
+ this.debugLog(`OpenAPI refresh unexpected statusCode=${statusCode} for ${deviceId} (attempt ${attempt + 1})`)
2210
+ } catch (e: any) {
2211
+ this.debugLog(`OpenAPI refresh error for ${deviceId} (attempt ${attempt + 1}): ${e?.message ?? e}`)
2212
+ }
2213
+ // backoff before next retry if any left
2214
+ attempt++
2215
+ if (attempt <= retries) {
2216
+ const delay = baseDelayMs * (2 ** (attempt - 1))
2217
+ await sleep(delay)
2218
+ }
2219
+ }
2220
+ return null
2221
+ }
2222
+
2223
+ /** Simple concurrency limiter for an array of items */
2224
+ private async runWithConcurrency<T>(items: T[], worker: (item: T) => Promise<void>, concurrency: number): Promise<void> {
2225
+ const queue = items.slice()
2226
+ const workers: Promise<void>[] = []
2227
+ const runNext = async (): Promise<void> => {
2228
+ const item = queue.shift()
2229
+ if (!item) {
2230
+ return
2231
+ }
2232
+ await worker(item)
2233
+ return runNext()
2234
+ }
2235
+ const pool = Math.min(concurrency, Math.max(1, items.length))
2236
+ for (let i = 0; i < pool; i++) {
2237
+ workers.push(runNext())
2238
+ }
2239
+ await Promise.all(workers)
2240
+ }
556
2241
  }