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

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