@switchbot/homebridge-switchbot 5.0.0-beta.2 → 5.0.0-beta.21

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 (107) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/config.schema.json +17 -2
  3. package/dist/devices-hap/device.d.ts +1 -0
  4. package/dist/devices-hap/device.d.ts.map +1 -1
  5. package/dist/devices-hap/device.js +70 -30
  6. package/dist/devices-hap/device.js.map +1 -1
  7. package/dist/devices-matter/BaseMatterAccessory.d.ts +23 -0
  8. package/dist/devices-matter/BaseMatterAccessory.d.ts.map +1 -1
  9. package/dist/devices-matter/BaseMatterAccessory.js +167 -5
  10. package/dist/devices-matter/BaseMatterAccessory.js.map +1 -1
  11. package/dist/devices-matter/ColorLightAccessory.d.ts.map +1 -1
  12. package/dist/devices-matter/ColorLightAccessory.js +12 -12
  13. package/dist/devices-matter/ColorLightAccessory.js.map +1 -1
  14. package/dist/devices-matter/ColorTemperatureLightAccessory.d.ts.map +1 -1
  15. package/dist/devices-matter/ColorTemperatureLightAccessory.js +5 -7
  16. package/dist/devices-matter/ColorTemperatureLightAccessory.js.map +1 -1
  17. package/dist/devices-matter/DimmableLightAccessory.js +9 -9
  18. package/dist/devices-matter/DimmableLightAccessory.js.map +1 -1
  19. package/dist/devices-matter/ExtendedColorLightAccessory.d.ts.map +1 -1
  20. package/dist/devices-matter/ExtendedColorLightAccessory.js +14 -15
  21. package/dist/devices-matter/ExtendedColorLightAccessory.js.map +1 -1
  22. package/dist/devices-matter/OnOffLightAccessory.d.ts.map +1 -1
  23. package/dist/devices-matter/OnOffLightAccessory.js +8 -16
  24. package/dist/devices-matter/OnOffLightAccessory.js.map +1 -1
  25. package/dist/devices-matter/OnOffOutletAccessory.d.ts +2 -0
  26. package/dist/devices-matter/OnOffOutletAccessory.d.ts.map +1 -1
  27. package/dist/devices-matter/OnOffOutletAccessory.js +10 -7
  28. package/dist/devices-matter/OnOffOutletAccessory.js.map +1 -1
  29. package/dist/devices-matter/OnOffSwitchAccessory.js +2 -2
  30. package/dist/devices-matter/OnOffSwitchAccessory.js.map +1 -1
  31. package/dist/devices-matter/baseMatterAccessory.test.d.ts +2 -0
  32. package/dist/devices-matter/baseMatterAccessory.test.d.ts.map +1 -0
  33. package/dist/devices-matter/baseMatterAccessory.test.js +71 -0
  34. package/dist/devices-matter/baseMatterAccessory.test.js.map +1 -0
  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 +10 -14
  45. package/dist/platform-hap.d.ts.map +1 -1
  46. package/dist/platform-hap.js +38 -64
  47. package/dist/platform-hap.js.map +1 -1
  48. package/dist/platform-matter.cleanup.test.d.ts +2 -0
  49. package/dist/platform-matter.cleanup.test.d.ts.map +1 -0
  50. package/dist/platform-matter.cleanup.test.js +85 -0
  51. package/dist/platform-matter.cleanup.test.js.map +1 -0
  52. package/dist/platform-matter.d.ts +68 -6
  53. package/dist/platform-matter.d.ts.map +1 -1
  54. package/dist/platform-matter.js +1250 -70
  55. package/dist/platform-matter.js.map +1 -1
  56. package/dist/platform-matter.mapping.test.d.ts +2 -0
  57. package/dist/platform-matter.mapping.test.d.ts.map +1 -0
  58. package/dist/platform-matter.mapping.test.js +50 -0
  59. package/dist/platform-matter.mapping.test.js.map +1 -0
  60. package/dist/platform-matter.test.d.ts +2 -0
  61. package/dist/platform-matter.test.d.ts.map +1 -0
  62. package/dist/platform-matter.test.js +127 -0
  63. package/dist/platform-matter.test.js.map +1 -0
  64. package/dist/platform-matter.unregister.test.d.ts +2 -0
  65. package/dist/platform-matter.unregister.test.d.ts.map +1 -0
  66. package/dist/platform-matter.unregister.test.js +37 -0
  67. package/dist/platform-matter.unregister.test.js.map +1 -0
  68. package/dist/settings.d.ts +1 -0
  69. package/dist/settings.d.ts.map +1 -1
  70. package/dist/settings.js.map +1 -1
  71. package/dist/utils.d.ts +87 -0
  72. package/dist/utils.d.ts.map +1 -1
  73. package/dist/utils.js +254 -0
  74. package/dist/utils.js.map +1 -1
  75. package/dist/utils.test.d.ts +2 -0
  76. package/dist/utils.test.d.ts.map +1 -0
  77. package/dist/utils.test.js +95 -0
  78. package/dist/utils.test.js.map +1 -0
  79. package/dist/verifyconfig.test.js +2 -2
  80. package/dist/verifyconfig.test.js.map +1 -1
  81. package/docs/assets/main.js +2 -2
  82. package/docs/index.html +2 -2
  83. package/docs/variables/default.html +1 -1
  84. package/package.json +14 -14
  85. package/src/devices-hap/device.ts +68 -30
  86. package/src/devices-matter/BaseMatterAccessory.ts +168 -5
  87. package/src/devices-matter/ColorLightAccessory.ts +12 -12
  88. package/src/devices-matter/ColorTemperatureLightAccessory.ts +5 -7
  89. package/src/devices-matter/DimmableLightAccessory.ts +9 -9
  90. package/src/devices-matter/ExtendedColorLightAccessory.ts +14 -15
  91. package/src/devices-matter/OnOffLightAccessory.ts +8 -16
  92. package/src/devices-matter/OnOffOutletAccessory.ts +12 -7
  93. package/src/devices-matter/OnOffSwitchAccessory.ts +2 -2
  94. package/src/devices-matter/baseMatterAccessory.test.ts +88 -0
  95. package/src/index.test.ts +7 -2
  96. package/src/index.ts +4 -5
  97. package/src/irdevice/irdevice.ts +74 -35
  98. package/src/platform-hap.ts +39 -73
  99. package/src/platform-matter.cleanup.test.ts +101 -0
  100. package/src/platform-matter.mapping.test.ts +60 -0
  101. package/src/platform-matter.test.ts +155 -0
  102. package/src/platform-matter.ts +1286 -73
  103. package/src/platform-matter.unregister.test.ts +48 -0
  104. package/src/settings.ts +4 -0
  105. package/src/utils.test.ts +96 -0
  106. package/src/utils.ts +255 -0
  107. package/src/verifyconfig.test.ts +11 -10
@@ -5,8 +5,11 @@ import type {
5
5
  MatterAccessory,
6
6
  SerializedMatterAccessory,
7
7
  } from 'homebridge'
8
+ import type { bodyChange, device } from 'node-switchbot'
8
9
 
9
- import type { SwitchBotPlatformConfig } from './settings.js'
10
+ import type { devicesConfig, SwitchBotPlatformConfig } from './settings.js'
11
+
12
+ import { SwitchBotBLE, SwitchBotOpenAPI } from 'node-switchbot'
10
13
 
11
14
  import {
12
15
  ColorLightAccessory,
@@ -32,14 +35,8 @@ import {
32
35
  WindowBlindAccessory,
33
36
  } from './devices-matter/index.js'
34
37
  import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'
35
- import { cleanDeviceConfig } from './utils.js'
36
-
37
- /**
38
- * MatterPlatform
39
- * Demonstrates all available Matter device types in Homebridge
40
- *
41
- * Organized by official Matter Specification v1.4.1 categories
42
- */
38
+ import { cleanDeviceConfig, createPlatformLogger, formatDeviceIdAsMac, hs2rgb, makeBLESender, makeOpenAPISender, rgb2hs, sleep } from './utils.js'
39
+
43
40
  export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
44
41
  // Track restored HAP cached accessories (required for DynamicPlatformPlugin)
45
42
  // This is commented out here as this plugin does not have any HAP accessories
@@ -47,13 +44,52 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
47
44
 
48
45
  // Track restored Matter cached accessories
49
46
  public readonly matterAccessories: Map<string, SerializedMatterAccessory> = new Map()
47
+ // node-switchbot clients
48
+ private switchBotAPI?: SwitchBotOpenAPI
49
+ private switchBotBLE?: SwitchBotBLE
50
+ // discovered devices cache
51
+ private discoveredDevices: device[] = []
52
+ // Registry of created accessory instances keyed by normalized deviceId
53
+ private accessoryInstances: Map<string, any> = new Map()
54
+ // Refresh timers keyed by normalized deviceId
55
+ private refreshTimers: Map<string, NodeJS.Timeout> = new Map()
56
+ // BLE event handlers keyed by device MAC (formatted)
57
+ private bleEventHandler: { [x: string]: (context: any) => void } = {}
58
+ // Platform logging toggle (can be controlled via UI or config)
59
+ private platformLogging?: boolean
60
+
61
+ // Platform-provided logging helpers (attached in constructor)
62
+ infoLog!: (...args: any[]) => void
63
+ successLog!: (...args: any[]) => void
64
+ debugSuccessLog!: (...args: any[]) => void
65
+ warnLog!: (...args: any[]) => void
66
+ debugWarnLog!: (...args: any[]) => void
67
+ errorLog!: (...args: any[]) => void
68
+ debugErrorLog!: (...args: any[]) => void
69
+ debugLog!: (...args: any[]) => void
70
+ loggingIsDebug!: () => Promise<boolean>
71
+ enablingPlatformLogging!: () => Promise<boolean>
50
72
 
51
73
  constructor(
52
74
  public readonly log: Logging,
53
75
  public readonly config: SwitchBotPlatformConfig,
54
76
  public readonly api: API,
55
77
  ) {
56
- this.log.debug('Finished initializing platform:', this.config.name)
78
+ // Attach platform-wide logging helpers from utils so Matter and device
79
+ // classes can use consistent logging methods (infoLog/debugLog/etc.)
80
+ const _pl = createPlatformLogger(async () => (this as any).platformLogging, this.log)
81
+ this.infoLog = _pl.infoLog
82
+ this.successLog = _pl.successLog
83
+ this.debugSuccessLog = _pl.debugSuccessLog
84
+ this.warnLog = _pl.warnLog
85
+ this.debugWarnLog = _pl.debugWarnLog
86
+ this.errorLog = _pl.errorLog
87
+ this.debugErrorLog = _pl.debugErrorLog
88
+ this.debugLog = _pl.debugLog
89
+ this.loggingIsDebug = _pl.loggingIsDebug
90
+ this.enablingPlatformLogging = _pl.enablingPlatformLogging
91
+
92
+ this.debugLog('Finished initializing platform:', this.config.name)
57
93
 
58
94
  // Normalize deviceConfig to remove UI-inserted defaults
59
95
  try {
@@ -66,12 +102,12 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
66
102
  }
67
103
  }
68
104
  } catch (e) {
69
- this.log.debug('Failed to clean deviceConfig: %s', e)
105
+ this.debugLog('Failed to clean deviceConfig: %s', e)
70
106
  }
71
107
 
72
108
  // Does the user have a version of Homebridge that is compatible with matter?
73
109
  if (!this.api.isMatterAvailable?.()) {
74
- this.log.warn('Matter is not available in this version of Homebridge. Please update Homebridge to use this plugin.')
110
+ this.warnLog('Matter is not available in this version of Homebridge. Please update Homebridge to use this plugin.')
75
111
  }
76
112
 
77
113
  // Check if the user has matter enabled, this means:
@@ -80,17 +116,1120 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
80
116
  // In reality, only the below check is needed, but they are both included here for completeness
81
117
  // Remember to use a '?.' optional chaining operator in case the user is running an older version of Homebridge that does not have these APIs
82
118
  if (!this.api.isMatterEnabled?.()) {
83
- this.log.warn('Matter is not enabled in Homebridge. Please enable Matter in the Homebridge settings to use this plugin.')
119
+ this.warnLog('Matter is not enabled in Homebridge. Please enable Matter in the Homebridge settings to use this plugin.')
84
120
  return
85
121
  }
86
122
 
87
123
  // Register Matter accessories when Homebridge has finished launching
88
124
  this.api.on('didFinishLaunching', () => {
89
- this.log.debug('Executed didFinishLaunching callback')
90
- this.registerMatterAccessories()
125
+ this.debugLog('Executed didFinishLaunching callback')
126
+ // Initialize SwitchBot API clients
127
+ try {
128
+ if (this.config.credentials?.token && this.config.credentials?.secret) {
129
+ this.switchBotAPI = new SwitchBotOpenAPI(this.config.credentials.token, this.config.credentials.secret, this.config.options?.hostname)
130
+ // forward basic logs
131
+ if (!this.config.options?.disableLogsforOpenAPI && this.switchBotAPI?.on) {
132
+ this.switchBotAPI.on('log', (l: any) => this.debugLog('[SwitchBot OpenAPI]', l.message))
133
+ }
134
+ } else {
135
+ this.debugLog('SwitchBot OpenAPI credentials not provided; cloud devices will be skipped')
136
+ }
137
+ } catch (e: any) {
138
+ this.errorLog('Failed to initialize SwitchBot OpenAPI:', e?.message ?? e)
139
+ }
140
+
141
+ try {
142
+ this.switchBotBLE = new SwitchBotBLE()
143
+ if (!this.config.options?.disableLogsforBLE && this.switchBotBLE?.on) {
144
+ this.switchBotBLE.on('log', (l: any) => this.debugLog('[SwitchBot BLE]', l.message))
145
+ }
146
+ } catch (e: any) {
147
+ this.errorLog('Failed to initialize SwitchBot BLE client:', e?.message ?? e)
148
+ }
149
+
150
+ // If BLE scanning is enabled, start scanning and route advertisements to registered handlers
151
+ if (this.config.options?.BLE && this.switchBotBLE) {
152
+ const ble = this.switchBotBLE
153
+ ;(async () => {
154
+ try {
155
+ await ble.startScan()
156
+ } catch (e: any) {
157
+ this.errorLog(`Failed to start BLE scanning: ${e?.message ?? e}`)
158
+ }
159
+
160
+ // route advertisements to our handlers
161
+ ble.onadvertisement = async (ad: any) => {
162
+ try {
163
+ const mac = (ad.address || '').toLowerCase()
164
+ const handler = this.bleEventHandler[mac]
165
+ if (handler) {
166
+ await handler(ad.serviceData)
167
+ }
168
+ } catch (e: any) {
169
+ this.errorLog(`Failed to handle BLE advertisement: ${e?.message ?? e}`)
170
+ }
171
+ }
172
+ })()
173
+ }
174
+
175
+ // Ensure we clean up any per-device timers and BLE handlers when Homebridge shuts down
176
+ this.api.on('shutdown', async () => {
177
+ try {
178
+ this.infoLog('Homebridge shutting down: clearing refresh timers and BLE handlers')
179
+ // Clear all refresh timers
180
+ for (const [nid, t] of Array.from(this.refreshTimers.entries())) {
181
+ try {
182
+ clearInterval(t)
183
+ } catch (e: any) {
184
+ this.debugLog(`Failed to clear timer for ${nid}: ${e?.message ?? e}`)
185
+ }
186
+ this.refreshTimers.delete(nid)
187
+ }
188
+
189
+ // Clear accessory instances registry
190
+ try {
191
+ this.accessoryInstances.clear()
192
+ } catch (e: any) {
193
+ this.debugLog(`Failed to clear accessoryInstances: ${e?.message ?? e}`)
194
+ }
195
+
196
+ // Remove BLE handlers
197
+ try {
198
+ for (const k of Object.keys(this.bleEventHandler)) {
199
+ delete this.bleEventHandler[k]
200
+ }
201
+ } catch (e: any) {
202
+ this.debugLog(`Failed to clear bleEventHandler: ${e?.message ?? e}`)
203
+ }
204
+
205
+ // Stop BLE scanning if available
206
+ try {
207
+ if (this.switchBotBLE && typeof (this.switchBotBLE as any).stopScan === 'function') {
208
+ await (this.switchBotBLE as any).stopScan()
209
+ this.infoLog('Stopped BLE scanning')
210
+ }
211
+ } catch (e: any) {
212
+ this.debugLog(`Failed to stop BLE scanning: ${e?.message ?? e}`)
213
+ }
214
+ } catch (e: any) {
215
+ this.debugLog('Shutdown cleanup failed: %s', e?.message ?? e)
216
+ }
217
+ })
218
+
219
+ // perform device discovery from SwitchBot OpenAPI (if configured) and
220
+ // register Matter accessories after discovery completes. Previously we
221
+ // called discoverDevices() without awaiting it which caused registration
222
+ // to run before discovery finished and only example accessories were
223
+ // created. Use an async IIFE to sequentially await discovery then register.
224
+ ;(async () => {
225
+ try {
226
+ await this.discoverDevices()
227
+ } catch (e: any) {
228
+ this.debugLog('Device discovery failed during startup: %s', e?.message ?? e)
229
+ }
230
+
231
+ try {
232
+ await this.registerMatterAccessories()
233
+ } catch (e: any) {
234
+ this.errorLog('Failed to register Matter accessories: %s', e?.message ?? e)
235
+ }
236
+ })()
91
237
  })
92
238
  }
93
239
 
240
+ /**
241
+ * Normalize a deviceId for matching (uppercase alphanumerics only)
242
+ */
243
+ private normalizeDeviceId(deviceId: string) {
244
+ return (deviceId ?? '').toUpperCase().replace(/[^A-Z0-9]+/g, '')
245
+ }
246
+
247
+ /**
248
+ * Clear per-device resources: refresh timers, accessory instance registry, BLE handlers
249
+ */
250
+ private clearDeviceResources(deviceId?: string) {
251
+ if (!deviceId) {
252
+ return
253
+ }
254
+ try {
255
+ const nid = this.normalizeDeviceId(deviceId)
256
+ const existing = this.refreshTimers.get(nid)
257
+ if (existing) {
258
+ try {
259
+ clearInterval(existing)
260
+ } catch (e: any) {
261
+ this.debugLog(`Failed to clear refresh timer for ${deviceId}: ${e?.message ?? e}`)
262
+ }
263
+ this.refreshTimers.delete(nid)
264
+ }
265
+
266
+ try {
267
+ this.accessoryInstances.delete(nid)
268
+ } catch (e: any) {
269
+ this.debugLog(`Failed to delete accessory instance for ${deviceId}: ${e?.message ?? e}`)
270
+ }
271
+
272
+ try {
273
+ const mac = formatDeviceIdAsMac(deviceId).toLowerCase()
274
+ if (this.bleEventHandler[mac]) {
275
+ delete this.bleEventHandler[mac]
276
+ }
277
+ } catch (e: any) {
278
+ // formatting failed (not a MAC-like id) — ignore
279
+ this.debugLog(`clearDeviceResources: failed to remove BLE handler for ${deviceId}: ${e?.message ?? e}`)
280
+ }
281
+ } catch (e: any) {
282
+ this.debugLog(`clearDeviceResources top-level error for ${deviceId}: ${e?.message ?? e}`)
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Merge two arrays by deviceId. For each item in a1 (user-provided devices list),
288
+ * find matching item in a2 (discovered devices) and merge them with user overrides last.
289
+ */
290
+ private mergeByDeviceId(a1: { deviceId: string }[], a2: any[]) {
291
+ const allowConfigOnly = Boolean(this.config.options?.allowConfigOnlyDevices)
292
+ const result: any[] = []
293
+ for (const itm of (a1 || [])) {
294
+ const matchingItem = (a2 || []).find(item => this.normalizeDeviceId(item.deviceId) === this.normalizeDeviceId(itm.deviceId))
295
+ if (matchingItem) {
296
+ result.push(Object.assign({}, matchingItem, itm))
297
+ } else if (allowConfigOnly) {
298
+ // include config-only device as-is when explicitly allowed
299
+ result.push(Object.assign({}, itm))
300
+ }
301
+ // otherwise skip config-only entries
302
+ }
303
+ return result
304
+ }
305
+
306
+ /**
307
+ * Merge discovered devices with deviceConfig (per deviceType) and per-device overrides
308
+ * from `config.options.devices`, matching the behavior used in platform-hap.
309
+ */
310
+ private async mergeDiscoveredDevices(discovered: device[]): Promise<any[]> {
311
+ // If there's no device config or per-device config, return discovered as-is
312
+ if (!this.config.options?.devices && !this.config.options?.deviceConfig) {
313
+ return discovered
314
+ }
315
+
316
+ // Step 1: Assign missing deviceType from configDeviceType and merge deviceType-level configs
317
+ const devicesWithTypeConfig = await Promise.all(discovered.map(async (deviceObj) => {
318
+ if (!deviceObj.deviceType) {
319
+ deviceObj.deviceType = (deviceObj as any).configDeviceType !== undefined ? (deviceObj as any).configDeviceType : 'Unknown'
320
+ this.debugLog(`API missing deviceType for ${deviceObj.deviceId}, using configDeviceType: ${(deviceObj as any).configDeviceType}`)
321
+ }
322
+ const deviceTypeConfig = this.config.options?.deviceConfig?.[deviceObj.deviceType] || {}
323
+ return Object.assign({}, deviceObj, deviceTypeConfig)
324
+ }))
325
+
326
+ // Merge per-device overrides by matching deviceId
327
+ const merged = this.mergeByDeviceId(this.config.options?.devices ?? [], devicesWithTypeConfig ?? [])
328
+
329
+ // For any entries in merged (which are based on config.options.devices), ensure final per-device merges include deviceId-specific config
330
+ const final: any[] = []
331
+ for (const device of merged) {
332
+ // Find per-device config entry by deviceId (config.options.devices is an array)
333
+ const deviceIdConfig = (this.config.options?.devices || []).find((d: any) => this.normalizeDeviceId(d.deviceId) === this.normalizeDeviceId(device.deviceId)) || {}
334
+ const deviceWithConfig = Object.assign({}, device, deviceIdConfig)
335
+ final.push(deviceWithConfig)
336
+ }
337
+
338
+ // Also include any discovered devices that weren't present in the user devices list
339
+ const userDeviceIds = new Set((this.config.options?.devices || []).map((d: any) => this.normalizeDeviceId(d.deviceId)))
340
+ for (const d of devicesWithTypeConfig) {
341
+ if (!userDeviceIds.has(this.normalizeDeviceId(d.deviceId))) {
342
+ final.push(d)
343
+ }
344
+ }
345
+
346
+ return final
347
+ }
348
+
349
+ /**
350
+ * Select effective connection type for a device: prefer explicit device.connectionType,
351
+ * otherwise prefer BLE when platform BLE is enabled and device provides a BLE model/id.
352
+ */
353
+ private chooseConnectionType(deviceObj: any): 'BLE' | 'OpenAPI' {
354
+ if (deviceObj?.connectionType) {
355
+ return deviceObj.connectionType === 'BLE' ? 'BLE' : 'OpenAPI'
356
+ }
357
+ // If platform BLE is enabled and we have a bleModel or deviceId that formats to a MAC, prefer BLE
358
+ if (this.config.options?.BLE && (deviceObj?.bleModel || formatDeviceIdAsMac(deviceObj?.deviceId))) {
359
+ return 'BLE'
360
+ }
361
+ return 'OpenAPI'
362
+ }
363
+
364
+ /**
365
+ * Map a SwitchBot device object to a MatterAccessory using the device-specific
366
+ * Matter accessory classes in `src/devices-matter`.
367
+ */
368
+ private async createAccessoryFromDevice(dev: device & devicesConfig): Promise<MatterAccessory<Record<string, unknown>> | undefined> {
369
+ // Basic metadata
370
+ const displayName = dev.deviceName ?? dev.deviceId ?? 'SwitchBot Device'
371
+ const serial = dev.deviceId ?? 'unknown'
372
+ const manufacturer = 'SwitchBot'
373
+ const model = dev.model ?? dev.deviceType ?? 'SwitchBot'
374
+ const firmware = (dev as any).firmware ?? (dev as any).version ?? '0.0.0'
375
+
376
+ // Helper to build a default opts object consumed by the matter device classes
377
+ const baseOpts = {
378
+ uuid: this.api.matter.uuid.generate(serial),
379
+ displayName,
380
+ serialNumber: serial,
381
+ manufacturer,
382
+ model,
383
+ firmwareRevision: String(firmware),
384
+ hardwareRevision: '1.0.0',
385
+ deviceId: dev.deviceId,
386
+ // Inject handy platform-side helpers into the accessory `context` so Matter
387
+ // accessory classes can perform OpenAPI/BLE actions without reaching into
388
+ // the platform implementation directly.
389
+ context: {
390
+ deviceId: dev.deviceId,
391
+ // Expose the display name so Matter accessory classes can read it from context
392
+ name: displayName,
393
+ // Provide device-level logging override (if present) and platform logging flag
394
+ // so accessories can decide how verbose they should be.
395
+ deviceLogging: (dev as any)?.logging,
396
+ platformLogging: this.platformLogging,
397
+ },
398
+ }
399
+
400
+ // Build platform-side helpers using shared factories so they can be reused/tested
401
+ const sendOpenAPI = makeOpenAPISender(this.retryCommand.bind(this), dev, { maxRetries: this.config.options?.maxRetries ?? 1, delayBetweenRetries: this.config.options?.delayBetweenRetries ?? 1000 })
402
+ const sendBLE = makeBLESender(this.switchBotBLE, dev, { bleRetries: (this.config.options as any)?.bleRetries ?? 2, bleRetryDelay: (this.config.options as any)?.bleRetryDelay ?? 500 })
403
+
404
+ // Log that we're initializing this device so it's visible in startup logs
405
+ try {
406
+ this.infoLog(`Initializing Matter device: ${displayName} (type=${dev.deviceType ?? 'Unknown'}) id=${dev.deviceId}`)
407
+ } catch (e: any) {
408
+ // best-effort logging — swallow errors to avoid breaking initialization
409
+ this.debugLog('Failed to log initializing device:', e?.message ?? e)
410
+ }
411
+
412
+ const makeOnOffHandlers = (uuid: string, connectionType: 'BLE' | 'OpenAPI') => ({
413
+ onOff: {
414
+ on: async () => {
415
+ try {
416
+ if (connectionType === 'BLE' && this.switchBotBLE) {
417
+ await sendBLE('turnOn')
418
+ } else {
419
+ await sendOpenAPI('turnOn')
420
+ }
421
+ await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.OnOff, { onOff: true })
422
+ } catch (e: any) {
423
+ this.errorLog(`Failed to turn on device ${dev.deviceId}: ${e?.message ?? e}`)
424
+ }
425
+ },
426
+ off: async () => {
427
+ try {
428
+ if (connectionType === 'BLE' && this.switchBotBLE) {
429
+ await sendBLE('turnOff')
430
+ } else {
431
+ await sendOpenAPI('turnOff')
432
+ }
433
+ await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.OnOff, { onOff: false })
434
+ } catch (e: any) {
435
+ this.errorLog(`Failed to turn off device ${dev.deviceId}: ${e?.message ?? e}`)
436
+ }
437
+ },
438
+ },
439
+ })
440
+
441
+ // Mapping from SwitchBot deviceType -> constructor (expanded for parity with HAP)
442
+ const mapping: { [key: string]: any } = {
443
+ // Plugs / Outlets
444
+ 'Plug': OnOffOutletAccessory,
445
+ 'Plug Mini (US)': OnOffOutletAccessory,
446
+ 'Plug Mini (JP)': OnOffOutletAccessory,
447
+ 'Plug Mini': OnOffOutletAccessory,
448
+ 'WoPlug': OnOffOutletAccessory,
449
+
450
+ // Lighting
451
+ 'Color Bulb': ColorLightAccessory,
452
+ 'Color Bulb Mini': ColorLightAccessory,
453
+ 'Ceiling Light': ColorTemperatureLightAccessory,
454
+ 'Ceiling Light Pro': ColorTemperatureLightAccessory,
455
+ 'Strip Light': ExtendedColorLightAccessory,
456
+ 'Light Strip': ExtendedColorLightAccessory,
457
+ 'Light Strip Plus': ExtendedColorLightAccessory,
458
+ 'Strip Light Pro': ExtendedColorLightAccessory,
459
+ 'Dimmable Light': DimmableLightAccessory,
460
+
461
+ // Robot Vacuums
462
+ 'K10+': RoboticVacuumAccessory,
463
+ 'K10+ Pro': RoboticVacuumAccessory,
464
+ 'WoSweeper': RoboticVacuumAccessory,
465
+ 'WoSweeperMini': RoboticVacuumAccessory,
466
+ 'Robot Vacuum Cleaner S1': RoboticVacuumAccessory,
467
+ 'Robot Vacuum Cleaner S1 Plus': RoboticVacuumAccessory,
468
+ 'Robot Vacuum Cleaner S10': RoboticVacuumAccessory,
469
+ 'Robot Vacuum Cleaner S1 Pro': RoboticVacuumAccessory,
470
+ 'Robot Vacuum Cleaner S1 Mini': RoboticVacuumAccessory,
471
+
472
+ // Locks
473
+ 'Smart Lock': DoorLockAccessory,
474
+ 'Smart Lock Pro': DoorLockAccessory,
475
+
476
+ // Sensors
477
+ 'Motion Sensor': OccupancySensorAccessory,
478
+ 'Contact Sensor': ContactSensorAccessory,
479
+ 'Water Detector': LeakSensorAccessory,
480
+ 'Meter': TemperatureSensorAccessory,
481
+ 'MeterPlus': TemperatureSensorAccessory,
482
+ 'MeterPro': TemperatureSensorAccessory,
483
+ 'WoIOSensor': TemperatureSensorAccessory,
484
+ 'Air Purifier PM2.5': HumiditySensorAccessory,
485
+ 'Air Purifier Table PM2.5': HumiditySensorAccessory,
486
+ 'Air Purifier': HumiditySensorAccessory,
487
+ 'Air Purifier VOC': HumiditySensorAccessory,
488
+ 'Air Purifier Table VOC': HumiditySensorAccessory,
489
+
490
+ // Fans
491
+ 'Battery Circulator Fan': FanAccessory,
492
+
493
+ // Curtains / Blinds
494
+ 'Blind Tilt': VenetianBlindAccessory,
495
+ 'Curtain': WindowBlindAccessory,
496
+ 'Curtain2': WindowBlindAccessory,
497
+ 'Curtain3': WindowBlindAccessory,
498
+ 'Curtain 2': WindowBlindAccessory,
499
+ 'WoRollerShade': WindowBlindAccessory,
500
+ 'Roller Shade': WindowBlindAccessory,
501
+ 'Venetian Blind': VenetianBlindAccessory,
502
+
503
+ // Switches / Relays
504
+ 'Relay Switch 1': OnOffSwitchAccessory,
505
+ 'Relay Switch 1PM': OnOffSwitchAccessory,
506
+ 'Relay Switch 2': OnOffSwitchAccessory,
507
+ 'Relay Switch 3': OnOffSwitchAccessory,
508
+
509
+ // Misc / hubs / other
510
+ 'Hub 2': undefined,
511
+ 'Hub 3': undefined,
512
+ 'Hub Mini': undefined,
513
+ 'Bot': OnOffSwitchAccessory,
514
+ 'Smart Bot': OnOffSwitchAccessory,
515
+ 'Humidifier': HumiditySensorAccessory,
516
+ 'Humidifier2': HumiditySensorAccessory,
517
+ 'Thermostat': ThermostatAccessory,
518
+ 'Water Heater': ThermostatAccessory,
519
+ }
520
+
521
+ const Ctor = mapping[dev.deviceType ?? '']
522
+ if (!Ctor) {
523
+ this.debugLog(`No Matter mapping for deviceType='${dev.deviceType}', deviceId=${dev.deviceId}`)
524
+ return undefined
525
+ }
526
+
527
+ // Build opts and handlers tailored for basic capabilities
528
+ const uuid = baseOpts.uuid
529
+ const handlers: Record<string, any> = {}
530
+
531
+ // Choose connection type for this device (BLE vs OpenAPI)
532
+ const connectionType = this.chooseConnectionType(dev)
533
+
534
+ // On/Off common
535
+ handlers.onOff = makeOnOffHandlers(uuid, connectionType).onOff
536
+
537
+ // If this is a light, add brightness and color handlers
538
+ if (['Color Bulb', 'Ceiling Light', 'Ceiling Light Pro', 'Strip Light', 'Dimmable Light'].includes(dev.deviceType ?? '')) {
539
+ // levelControl
540
+ handlers.levelControl = {
541
+ moveToLevelWithOnOff: async (request: any) => {
542
+ try {
543
+ const level = request.level as number
544
+ const percent = Math.round((level / 254) * 100)
545
+ if (connectionType === 'BLE' && this.switchBotBLE) {
546
+ await sendBLE('setBrightness', percent)
547
+ } else {
548
+ await sendOpenAPI('setBrightness', `${percent}`)
549
+ }
550
+ await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.LevelControl, { currentLevel: level })
551
+ } catch (e: any) {
552
+ this.errorLog(`Failed to set brightness for ${dev.deviceId}: ${e?.message ?? e}`)
553
+ }
554
+ },
555
+ }
556
+
557
+ // colorControl
558
+ handlers.colorControl = {
559
+ moveToHueAndSaturationLogic: async (request: any) => {
560
+ try {
561
+ const hue = request.hue as number
562
+ const saturation = request.saturation as number
563
+ const [r, g, b] = hs2rgb(Math.round((hue / 254) * 360), Math.round((saturation / 254) * 100))
564
+ if (connectionType === 'BLE' && this.switchBotBLE) {
565
+ await sendBLE('setRGB', Number(request.level ?? 100), r, g, b)
566
+ } else {
567
+ await sendOpenAPI('setColor', `${r}:${g}:${b}`)
568
+ }
569
+ await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentHue: hue, currentSaturation: saturation })
570
+ } catch (e: any) {
571
+ this.errorLog(`Failed to set hue/sat for ${dev.deviceId}: ${e?.message ?? e}`)
572
+ }
573
+ },
574
+ moveToColorLogic: async (request: any) => {
575
+ try {
576
+ // MoveToColor gives colorX/colorY values; convert to approximate RGB by mapping to 0-255 scale
577
+ const colorX = request.colorX as number
578
+ const colorY = request.colorY as number
579
+ // Naive conversion: map X/Y into RGB via hue approximation (not colorimetrically accurate)
580
+ const hueApprox = Math.round((colorX / 65535) * 360)
581
+ const satApprox = Math.round((colorY / 65535) * 100)
582
+ const [r, g, b] = hs2rgb(hueApprox, satApprox)
583
+ if (connectionType === 'BLE' && this.switchBotBLE) {
584
+ await sendBLE('setRGB', Number(request.level ?? 100), r, g, b)
585
+ } else {
586
+ await sendOpenAPI('setColor', `${r}:${g}:${b}`)
587
+ }
588
+ await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentX: colorX, currentY: colorY })
589
+ } catch (e: any) {
590
+ this.errorLog(`Failed to set XY color for ${dev.deviceId}: ${e?.message ?? e}`)
591
+ }
592
+ },
593
+ }
594
+
595
+ // color temperature — map to kelvin and send setColorTemperature
596
+ handlers.colorTemperature = {
597
+ moveToColorTemperature: async (request: any) => {
598
+ try {
599
+ const kelvin = Math.round(1000000 / Number(request.colorTemperature))
600
+ if (connectionType === 'BLE' && this.switchBotBLE) {
601
+ await sendBLE('setColorTemperature', kelvin)
602
+ } else {
603
+ await sendOpenAPI('setColorTemperature', `${kelvin}`)
604
+ }
605
+ await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentX: request.colorX ?? 0, currentY: request.colorY ?? 0 })
606
+ } catch (e: any) {
607
+ this.errorLog(`Failed to set color temperature for ${dev.deviceId}: ${e?.message ?? e}`)
608
+ }
609
+ },
610
+ }
611
+ }
612
+
613
+ // Expose platform helpers to the accessory via context so accessory
614
+ // classes can call OpenAPI/BLE actions (sendOpenAPI/sendBLE) and know
615
+ // the effective connection type.
616
+ try {
617
+ /* Inject platform helpers (OpenAPI/BLE senders + logging helpers + connection type)
618
+ into the accessory context so Matter accessory classes can use them without
619
+ reaching into the platform implementation directly. */
620
+ ;(baseOpts as any).context = Object.assign({}, (baseOpts as any).context, {
621
+ sendOpenAPI,
622
+ sendBLE,
623
+ connectionType,
624
+ // Expose platform logging helpers so accessories can use consistent logging
625
+ infoLog: this.infoLog,
626
+ debugLog: this.debugLog,
627
+ warnLog: this.warnLog,
628
+ errorLog: this.errorLog,
629
+ successLog: this.successLog,
630
+ })
631
+ } catch (e: any) {
632
+ this.debugLog('Failed to attach platform helpers to baseOpts.context: %s', e?.message ?? e)
633
+ }
634
+
635
+ const opts = Object.assign({}, baseOpts, { handlers })
636
+
637
+ // Instantiate the device class and return its serialized accessory
638
+ const instance = new Ctor(this.api, this.log, opts)
639
+ // Save instance in registry so platform can call device-specific update methods if needed
640
+ try {
641
+ if (dev?.deviceId) {
642
+ this.accessoryInstances.set(this.normalizeDeviceId(dev.deviceId), instance)
643
+ }
644
+ } catch (e: any) {
645
+ this.debugLog('Failed to register accessory instance: %s', e?.message ?? e)
646
+ }
647
+ try {
648
+ this.infoLog(`Initialized Matter accessory: ${displayName} (type=${dev.deviceType ?? 'Unknown'}) id=${dev.deviceId}`)
649
+ } catch (e: any) {
650
+ this.debugLog('Failed to log initialized accessory:', e?.message ?? e)
651
+ }
652
+
653
+ // Register BLE->Matter push handler for this device's MAC (if BLE scanning is active)
654
+ try {
655
+ const mac = formatDeviceIdAsMac(dev.deviceId).toLowerCase()
656
+ // Handler receives advertisement/serviceData when BLE scan events arrive
657
+ this.bleEventHandler[mac] = async (serviceData?: any) => {
658
+ const uuidLocal = baseOpts.uuid
659
+
660
+ // First try model-specific / normalized parsing of BLE advertisement
661
+ try {
662
+ const parsed = this.parseAdvertisementForDevice(dev, serviceData)
663
+ if (parsed) {
664
+ // Power
665
+ if (parsed.power !== undefined) {
666
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.OnOff, { onOff: Boolean(parsed.power) })
667
+ }
668
+
669
+ // Brightness
670
+ if (parsed.brightness !== undefined) {
671
+ const level = Math.round((Number(parsed.brightness) / 100) * 254)
672
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.LevelControl, { currentLevel: level })
673
+ }
674
+
675
+ // Color
676
+ if (parsed.color !== undefined) {
677
+ const { r, g, b } = parsed.color
678
+ const [h, s] = rgb2hs(r, g, b)
679
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.ColorControl, { currentHue: Math.round((h / 360) * 254), currentSaturation: Math.round((s / 100) * 254) })
680
+ }
681
+
682
+ // Battery -> powerSource cluster (common mapping)
683
+ if (parsed.battery !== undefined) {
684
+ try {
685
+ const percentage = Number(parsed.battery)
686
+ const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)))
687
+ let batChargeLevel = 0
688
+ if (percentage < 20) {
689
+ batChargeLevel = 2
690
+ } else if (percentage < 40) {
691
+ batChargeLevel = 1
692
+ }
693
+ await this.api.matter.updateAccessoryState(uuidLocal, 'powerSource', { batPercentRemaining, batChargeLevel })
694
+ } catch (e: any) {
695
+ this.debugLog(`Failed to update battery state for ${dev.deviceId}: ${e?.message ?? e}`)
696
+ }
697
+ }
698
+
699
+ // Temperature -> temperatureMeasurement
700
+ if (parsed.temperature !== undefined) {
701
+ try {
702
+ const c = Number(parsed.temperature)
703
+ const measured = Math.round(c * 100)
704
+ await this.api.matter.updateAccessoryState(uuidLocal, 'temperatureMeasurement', { measuredValue: measured })
705
+ } catch (e: any) {
706
+ this.debugLog(`Failed to update temperature for ${dev.deviceId}: ${e?.message ?? e}`)
707
+ }
708
+ }
709
+
710
+ // Humidity -> relativeHumidityMeasurement
711
+ if (parsed.humidity !== undefined) {
712
+ try {
713
+ const percent = Number(parsed.humidity)
714
+ const measured = Math.round(percent * 100)
715
+ await this.api.matter.updateAccessoryState(uuidLocal, 'relativeHumidityMeasurement', { measuredValue: measured })
716
+ } catch (e: any) {
717
+ this.debugLog(`Failed to update humidity for ${dev.deviceId}: ${e?.message ?? e}`)
718
+ }
719
+ }
720
+
721
+ // Contact / Leak -> BooleanState
722
+ if (parsed.contact !== undefined || parsed.leak !== undefined) {
723
+ try {
724
+ // Some devices report contact as true=open; ContactSensor expects inverted value
725
+ const isContactOpen = parsed.contact === undefined ? undefined : Boolean(parsed.contact)
726
+ const leakDetected = parsed.leak === undefined ? undefined : Boolean(parsed.leak)
727
+
728
+ if (isContactOpen !== undefined) {
729
+ // If this is a contact sensor device type, invert; otherwise set conservatively
730
+ if ((dev.deviceType || '').includes('Contact')) {
731
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: !isContactOpen })
732
+ } else {
733
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: isContactOpen })
734
+ }
735
+ }
736
+
737
+ if (leakDetected !== undefined) {
738
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: leakDetected })
739
+ }
740
+ } catch (e: any) {
741
+ this.debugLog(`Failed to update contact/leak for ${dev.deviceId}: ${e?.message ?? e}`)
742
+ }
743
+ }
744
+
745
+ // Motion -> occupancy
746
+ if (parsed.motion !== undefined) {
747
+ try {
748
+ await this.api.matter.updateAccessoryState(uuidLocal, 'occupancySensing', { occupancy: { occupied: Boolean(parsed.motion) } })
749
+ } catch (e: any) {
750
+ this.debugLog(`Failed to update occupancy for ${dev.deviceId}: ${e?.message ?? e}`)
751
+ }
752
+ }
753
+
754
+ // Lock state -> doorLock
755
+ if (parsed.lock !== undefined) {
756
+ try {
757
+ const s = String(parsed.lock).toLowerCase()
758
+ let lockState = 0
759
+ if (s === 'locked' || s === '1' || s === 'true') {
760
+ lockState = 1
761
+ } else if (s === 'unlocked' || s === '0' || s === 'false') {
762
+ lockState = 2
763
+ }
764
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.DoorLock, { lockState })
765
+ } catch (e: any) {
766
+ this.debugLog(`Failed to update lock for ${dev.deviceId}: ${e?.message ?? e}`)
767
+ }
768
+ }
769
+
770
+ // Position / Cover -> WindowCovering (convert open percent to closed*100)
771
+ if (parsed.position !== undefined) {
772
+ try {
773
+ const openPercent = Number(parsed.position)
774
+ const closedPercent = 100 - Math.max(0, Math.min(100, openPercent))
775
+ const value = Math.round(closedPercent * 100)
776
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value })
777
+ } catch (e: any) {
778
+ this.debugLog(`Failed to update cover position for ${dev.deviceId}: ${e?.message ?? e}`)
779
+ }
780
+ }
781
+
782
+ // Fan speed -> FanControl
783
+ if (parsed.fanSpeed !== undefined) {
784
+ try {
785
+ const percent = Number(parsed.fanSpeed)
786
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent })
787
+ } catch (e: any) {
788
+ this.debugLog(`Failed to update fan speed for ${dev.deviceId}: ${e?.message ?? e}`)
789
+ }
790
+ }
791
+
792
+ // Robot vacuum fields (battery already handled) - update run/operational/clean modes when present
793
+ if (parsed.rvcRunMode !== undefined) {
794
+ try {
795
+ await this.api.matter.updateAccessoryState(uuidLocal, 'rvcRunMode', { currentMode: Number(parsed.rvcRunMode) })
796
+ } catch (e: any) {
797
+ this.debugLog(`Failed to update rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`)
798
+ }
799
+ }
800
+
801
+ if (parsed.rvcOperationalState !== undefined) {
802
+ try {
803
+ await this.api.matter.updateAccessoryState(uuidLocal, 'rvcOperationalState', { operationalState: Number(parsed.rvcOperationalState) })
804
+ } catch (e: any) {
805
+ this.debugLog(`Failed to update rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`)
806
+ }
807
+ }
808
+
809
+ // If we parsed something from serviceData prefer it and return early
810
+ if (serviceData) {
811
+ return
812
+ }
813
+ }
814
+ } catch (e: any) {
815
+ this.debugLog(`BLE advertisement parsing failed for ${dev.deviceId}: ${e?.message ?? e}`)
816
+ }
817
+
818
+ // Fallback to OpenAPI getDeviceStatus when serviceData is not present or parsing failed
819
+ if (!this.switchBotAPI) {
820
+ return
821
+ }
822
+ try {
823
+ const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret)
824
+ if (!(statusCode === 100 || statusCode === 200)) {
825
+ return
826
+ }
827
+ const respAny: any = response
828
+ const body = respAny?.body ?? respAny
829
+ const status = body?.status ?? body
830
+
831
+ // Use centralized mapper which prefers accessory instance update helpers
832
+ await this.applyStatusToAccessory(uuidLocal, dev, status)
833
+ } catch (e: any) {
834
+ this.debugLog(`BLE push handler failed for ${dev.deviceId}: ${e?.message ?? e}`)
835
+ }
836
+ }
837
+ } catch (e: any) {
838
+ this.debugLog(`Failed to register BLE handler for ${dev.deviceId}: ${e?.message ?? e}`)
839
+ }
840
+
841
+ // Schedule periodic OpenAPI refreshes for this device (if OpenAPI is configured)
842
+ try {
843
+ const nid = this.normalizeDeviceId(dev.deviceId)
844
+ // Clear any existing timer for this device
845
+ const existing = this.refreshTimers.get(nid)
846
+ if (existing) {
847
+ clearInterval(existing)
848
+ this.refreshTimers.delete(nid)
849
+ }
850
+
851
+ const refreshRateSec = dev.refreshRate ?? this.config.options?.refreshRate ?? 300
852
+ if (this.switchBotAPI && refreshRateSec && Number(refreshRateSec) > 0) {
853
+ // Immediate one-shot to populate initial state
854
+ ;(async () => {
855
+ try {
856
+ const { response, statusCode } = await this.switchBotAPI!.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret)
857
+ if (statusCode === 100 || statusCode === 200) {
858
+ const respAny: any = response
859
+ const body = respAny?.body ?? respAny
860
+ const status = body?.status ?? body
861
+ await this.applyStatusToAccessory(uuid, dev, status)
862
+ }
863
+ } catch (e: any) {
864
+ this.debugLog(`Initial OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`)
865
+ }
866
+ })()
867
+
868
+ const timer = setInterval(async () => {
869
+ try {
870
+ const { response, statusCode } = await this.switchBotAPI!.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret)
871
+ if (statusCode === 100 || statusCode === 200) {
872
+ const respAny: any = response
873
+ const body = respAny?.body ?? respAny
874
+ const status = body?.status ?? body
875
+ await this.applyStatusToAccessory(uuid, dev, status)
876
+ }
877
+ } catch (e: any) {
878
+ this.debugLog(`Periodic OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`)
879
+ }
880
+ }, Number(refreshRateSec) * 1000)
881
+
882
+ this.refreshTimers.set(nid, timer)
883
+ }
884
+ } catch (e: any) {
885
+ this.debugLog(`Failed to schedule refresh for ${dev.deviceId}: ${e?.message ?? e}`)
886
+ }
887
+
888
+ return instance.toAccessory()
889
+ }
890
+
891
+ /**
892
+ * Discover devices via SwitchBot OpenAPI and cache them for later use
893
+ */
894
+ private async discoverDevices(): Promise<void> {
895
+ if (!this.switchBotAPI) {
896
+ this.debugLog('SwitchBot OpenAPI not configured; skipping discovery')
897
+ return
898
+ }
899
+
900
+ try {
901
+ const { response, statusCode } = await this.switchBotAPI.getDevices()
902
+ this.debugLog(`SwitchBot getDevices response status: ${statusCode}`)
903
+ if (statusCode === 100 || statusCode === 200) {
904
+ const deviceList = Array.isArray(response?.body?.deviceList) ? response.body.deviceList : []
905
+ this.discoveredDevices = deviceList
906
+ this.infoLog(`Discovered ${deviceList.length} SwitchBot device(s) from OpenAPI`)
907
+ for (const d of deviceList) {
908
+ this.debugLog(` - ${d.deviceName} (${d.deviceType}) id=${d.deviceId}`)
909
+ }
910
+ } else {
911
+ this.warnLog(`SwitchBot getDevices returned status ${statusCode}`)
912
+ }
913
+ } catch (e: any) {
914
+ this.errorLog('Failed to discover SwitchBot devices:', e?.message ?? e)
915
+ }
916
+ }
917
+
918
+ /**
919
+ * Retry wrapper for control commands using SwitchBot OpenAPI
920
+ */
921
+ async retryCommand(deviceObj: device, bodyChange: bodyChange, maxRetries = 1, delayBetweenRetries = 1000): Promise<{ response: any, statusCode: number }> {
922
+ let retryCount = 0
923
+ while (retryCount < maxRetries) {
924
+ try {
925
+ if (!this.switchBotAPI) {
926
+ throw new Error('SwitchBot OpenAPI not initialized')
927
+ }
928
+ const { response, statusCode } = await this.switchBotAPI.controlDevice(
929
+ deviceObj.deviceId,
930
+ bodyChange.command,
931
+ bodyChange.parameter,
932
+ bodyChange.commandType as import('node-switchbot').commandType | undefined,
933
+ this.config.credentials?.token,
934
+ this.config.credentials?.secret,
935
+ )
936
+ return { response, statusCode }
937
+ } catch (e: any) {
938
+ this.debugLog(`retryCommand error: ${e?.message ?? e}`)
939
+ }
940
+ retryCount++
941
+
942
+ await sleep(delayBetweenRetries)
943
+ }
944
+ return { response: {}, statusCode: 500 }
945
+ }
946
+
947
+ /**
948
+ * Parse BLE advertisement/serviceData into normalized fields for a given device.
949
+ * Returns null when serviceData is falsy or parsing fails.
950
+ */
951
+ private parseAdvertisementForDevice(dev: device, serviceData?: any) {
952
+ if (!serviceData) {
953
+ return null
954
+ }
955
+ try {
956
+ const sd = serviceData
957
+ const result: any = {}
958
+
959
+ // Power/on state - supports multiple field names used by different models
960
+ const power = sd.power ?? sd.on ?? sd.p
961
+ if (power !== undefined) {
962
+ result.power = (String(power).toLowerCase() === 'on' || Number(power) === 1)
963
+ }
964
+
965
+ // Brightness (0-100)
966
+ const brightness = sd.brightness ?? sd.b
967
+ if (brightness !== undefined) {
968
+ result.brightness = Number(brightness)
969
+ }
970
+
971
+ // Color - could be 'r:g:b', '#rrggbb' or 'rrggbb'
972
+ const color = sd.color ?? sd.rgb ?? sd.c
973
+ if (color !== undefined) {
974
+ let r = 0
975
+ let g = 0
976
+ let b = 0
977
+ const c = String(color)
978
+ if (c.includes(':')) {
979
+ const parts = c.split(':').map(Number)
980
+ ;[r, g, b] = parts
981
+ } else if (c.includes(',')) {
982
+ const parts = c.split(',').map(s => Number(s.trim()))
983
+ ;[r, g, b] = parts
984
+ } else if (c.includes(' ')) {
985
+ const parts = c.split(' ').map(s => Number(s.trim()))
986
+ ;[r, g, b] = parts
987
+ } else if (c.startsWith('#')) {
988
+ const hex = c.replace('#', '')
989
+ r = Number.parseInt(hex.substring(0, 2), 16)
990
+ g = Number.parseInt(hex.substring(2, 4), 16)
991
+ b = Number.parseInt(hex.substring(4, 6), 16)
992
+ } else if (/^[0-9a-f]{6}$/i.test(c)) {
993
+ r = Number.parseInt(c.substring(0, 2), 16)
994
+ g = Number.parseInt(c.substring(2, 4), 16)
995
+ b = Number.parseInt(c.substring(4, 6), 16)
996
+ }
997
+ result.color = { r, g, b }
998
+ }
999
+
1000
+ // Battery (some devices use battery or batt)
1001
+ const battery = sd.battery ?? sd.batt
1002
+ if (battery !== undefined) {
1003
+ result.battery = Number(battery)
1004
+ }
1005
+
1006
+ return result
1007
+ } catch (e: any) {
1008
+ this.debugLog(`parseAdvertisementForDevice failed for ${dev.deviceId}: ${e?.message ?? e}`)
1009
+ return null
1010
+ }
1011
+ }
1012
+
1013
+ /**
1014
+ * Central helper to apply a SwitchBot status object to a Matter accessory.
1015
+ * Tries to call accessory instance update helpers when available, otherwise
1016
+ * falls back to calling api.matter.updateAccessoryState directly.
1017
+ */
1018
+ private async applyStatusToAccessory(uuidLocal: string, dev: device & devicesConfig, status: any) {
1019
+ if (!status) {
1020
+ return
1021
+ }
1022
+
1023
+ const instance = dev?.deviceId ? this.accessoryInstances.get(this.normalizeDeviceId(dev.deviceId)) : undefined
1024
+
1025
+ // Helper to safely call instance methods or fallback to api.matter.updateAccessoryState
1026
+ const safeUpdate = async (cluster: string, attributes: Record<string, unknown>, methodName?: string) => {
1027
+ try {
1028
+ if (instance && methodName && typeof instance[methodName] === 'function') {
1029
+ // prefer device-specific update helpers when available
1030
+ await instance[methodName](...(Object.values(attributes)))
1031
+ } else if (instance && typeof instance.updateState === 'function') {
1032
+ // some accessories expose updateState that accepts cluster and attributes
1033
+ await instance.updateState(cluster, attributes)
1034
+ } else {
1035
+ await this.api.matter.updateAccessoryState(uuidLocal, cluster, attributes)
1036
+ }
1037
+ } catch (e: any) {
1038
+ this.debugLog(`safeUpdate failed for ${dev.deviceId} cluster=${cluster}: ${e?.message ?? e}`)
1039
+ }
1040
+ }
1041
+
1042
+ try {
1043
+ // On/Off
1044
+ if (status?.power !== undefined) {
1045
+ const on = (String(status.power).toLowerCase() === 'on' || Number(status.power) === 1 || Boolean(status.power) === true)
1046
+ await safeUpdate(this.api.matter.clusterNames.OnOff, { onOff: on }, 'updateOnOffState')
1047
+ }
1048
+
1049
+ // Brightness
1050
+ if (status?.brightness !== undefined) {
1051
+ const level = Math.round((Number(status.brightness) / 100) * 254)
1052
+ await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: level }, 'updateBrightness')
1053
+ }
1054
+
1055
+ // Color
1056
+ if (status?.color !== undefined) {
1057
+ const color = String(status.color)
1058
+ let r = 0
1059
+ let g = 0
1060
+ let b = 0
1061
+ if (color.includes(':')) {
1062
+ const parts = color.split(':').map(Number)
1063
+ ;[r, g, b] = parts
1064
+ } else if (color.includes(',')) {
1065
+ const parts = color.split(',').map(s => Number(s.trim()))
1066
+ ;[r, g, b] = parts
1067
+ } else if (color.includes(' ')) {
1068
+ const parts = color.split(' ').map(s => Number(s.trim()))
1069
+ ;[r, g, b] = parts
1070
+ } else if (color.startsWith('#')) {
1071
+ const hex = color.replace('#', '')
1072
+ r = Number.parseInt(hex.substring(0, 2), 16)
1073
+ g = Number.parseInt(hex.substring(2, 4), 16)
1074
+ b = Number.parseInt(hex.substring(4, 6), 16)
1075
+ }
1076
+ const [h, s] = rgb2hs(r, g, b)
1077
+ await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: Math.round((h / 360) * 254), currentSaturation: Math.round((s / 100) * 254) }, 'updateHueSaturation')
1078
+ }
1079
+
1080
+ // Battery/powerSource (support many possible field names)
1081
+ if (status?.battery !== undefined || status?.batt !== undefined || status?.batteryLevel !== undefined || status?.batteryPercentage !== undefined || status?.battery_level !== undefined) {
1082
+ try {
1083
+ const percentage = Number(status?.battery ?? status?.batt ?? status?.batteryPercentage ?? status?.batteryLevel ?? status?.battery_level)
1084
+ const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)))
1085
+ let batChargeLevel = 0
1086
+ if (percentage < 20) {
1087
+ batChargeLevel = 2
1088
+ } else if (percentage < 40) {
1089
+ batChargeLevel = 1
1090
+ }
1091
+ const powerCluster = (this.api.matter?.clusterNames && (this.api.matter.clusterNames as any).PowerSource) ? (this.api.matter.clusterNames as any).PowerSource : 'powerSource'
1092
+ await safeUpdate(powerCluster, { batPercentRemaining, batChargeLevel }, 'updateBatteryPercentage')
1093
+ } catch (e: any) {
1094
+ this.debugLog(`Failed to apply battery status for ${dev.deviceId}: ${e?.message ?? e}`)
1095
+ }
1096
+ }
1097
+
1098
+ // Temperature + thermostat
1099
+ if (status?.temperature !== undefined || status?.temp !== undefined) {
1100
+ try {
1101
+ const c = Number(status?.temperature ?? status?.temp)
1102
+ const measured = Math.round(c * 100)
1103
+ await safeUpdate('temperatureMeasurement', { measuredValue: measured }, 'updateTemperature')
1104
+ // Thermostat-specific mapping
1105
+ if (status?.targetTemp !== undefined || status?.targetTemperature !== undefined || status?.heatingSetpoint !== undefined) {
1106
+ const target = Number(status?.targetTemp ?? status?.targetTemperature ?? status?.heatingSetpoint)
1107
+ const val = Math.round(target * 100)
1108
+ await safeUpdate('thermostat', { occupiedHeatingSetpoint: val }, 'updateHeatingSetpoint')
1109
+ }
1110
+ } catch (e: any) {
1111
+ this.debugLog(`Failed to apply temperature for ${dev.deviceId}: ${e?.message ?? e}`)
1112
+ }
1113
+ }
1114
+
1115
+ // Humidity (support different keys)
1116
+ if (status?.humidity !== undefined || status?.h !== undefined || status?.humid !== undefined) {
1117
+ try {
1118
+ const percent = Number(status?.humidity ?? status?.h ?? status?.humid)
1119
+ const measured = Math.round(percent * 100)
1120
+ await safeUpdate('relativeHumidityMeasurement', { measuredValue: measured }, 'updateHumidity')
1121
+ } catch (e: any) {
1122
+ this.debugLog(`Failed to apply humidity for ${dev.deviceId}: ${e?.message ?? e}`)
1123
+ }
1124
+ }
1125
+
1126
+ // Contact / Leak -> BooleanState
1127
+ if (status?.contact !== undefined || status?.open !== undefined || status?.leak !== undefined || status?.water !== undefined) {
1128
+ try {
1129
+ const isContactOpen = status?.contact ?? status?.open
1130
+ if (isContactOpen !== undefined) {
1131
+ if ((dev.deviceType || '').includes('Contact')) {
1132
+ await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: !(String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState')
1133
+ } else {
1134
+ await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: (String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState')
1135
+ }
1136
+ }
1137
+ const leakDetected = status?.leak ?? status?.water
1138
+ if (leakDetected !== undefined) {
1139
+ await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: Boolean(leakDetected) }, 'updateLeakState')
1140
+ }
1141
+ } catch (e: any) {
1142
+ this.debugLog(`Failed to apply contact/leak for ${dev.deviceId}: ${e?.message ?? e}`)
1143
+ }
1144
+ }
1145
+
1146
+ // Motion -> occupancy
1147
+ if (status?.motion !== undefined || status?.m !== undefined) {
1148
+ try {
1149
+ const detected = Boolean(status?.motion ?? status?.m)
1150
+ await safeUpdate('occupancySensing', { occupancy: { occupied: detected } }, 'updateOccupancy')
1151
+ } catch (e: any) {
1152
+ this.debugLog(`Failed to apply motion for ${dev.deviceId}: ${e?.message ?? e}`)
1153
+ }
1154
+ }
1155
+
1156
+ // Lock state
1157
+ if (status?.lock !== undefined) {
1158
+ try {
1159
+ const s = String(status.lock).toLowerCase()
1160
+ let lockState = 0
1161
+ if (s === 'locked' || s === '1' || s === 'true') {
1162
+ lockState = 1
1163
+ } else if (s === 'unlocked' || s === '0' || s === 'false') {
1164
+ lockState = 2
1165
+ }
1166
+ await safeUpdate(this.api.matter.clusterNames.DoorLock, { lockState }, 'updateLockState')
1167
+ } catch (e: any) {
1168
+ this.debugLog(`Failed to apply lock for ${dev.deviceId}: ${e?.message ?? e}`)
1169
+ }
1170
+ }
1171
+
1172
+ // Cover position
1173
+ if (status?.position !== undefined || status?.percent !== undefined) {
1174
+ try {
1175
+ const openPercent = Number(status?.position ?? status?.percent)
1176
+ const closedPercent = 100 - Math.max(0, Math.min(100, openPercent))
1177
+ const value = Math.round(closedPercent * 100)
1178
+ await safeUpdate(this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value }, 'updateLiftPosition')
1179
+ } catch (e: any) {
1180
+ this.debugLog(`Failed to apply cover position for ${dev.deviceId}: ${e?.message ?? e}`)
1181
+ }
1182
+ }
1183
+
1184
+ // Fan
1185
+ if (status?.fanSpeed !== undefined || status?.speed !== undefined) {
1186
+ try {
1187
+ const percent = Number(status?.fanSpeed ?? status?.speed)
1188
+ await safeUpdate(this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent }, 'updateFanSpeed')
1189
+ } catch (e: any) {
1190
+ this.debugLog(`Failed to apply fan speed for ${dev.deviceId}: ${e?.message ?? e}`)
1191
+ }
1192
+ }
1193
+
1194
+ // Robot vacuum: run/operational/clean modes
1195
+ if (status?.rvcRunMode !== undefined) {
1196
+ try {
1197
+ await safeUpdate('rvcRunMode', { currentMode: Number(status.rvcRunMode) }, 'updateRunMode')
1198
+ } catch (e: any) {
1199
+ this.debugLog(`Failed to apply rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`)
1200
+ }
1201
+ }
1202
+ // CO2 (carbon dioxide) - support common synonyms
1203
+ if (status?.co2 !== undefined || status?.co2ppm !== undefined || status?.carbonDioxide !== undefined) {
1204
+ try {
1205
+ const val = Number(status?.co2 ?? status?.co2ppm ?? status?.carbonDioxide)
1206
+ await safeUpdate('carbonDioxide', { carbonDioxideLevel: val }, 'updateCO2')
1207
+ } catch (e: any) {
1208
+ this.debugLog(`Failed to apply CO2 for ${dev.deviceId}: ${e?.message ?? e}`)
1209
+ }
1210
+ }
1211
+
1212
+ // PM2.5 / particulate matter
1213
+ if (status?.pm2_5 !== undefined || status?.pm25 !== undefined || status?.pm_2_5 !== undefined) {
1214
+ try {
1215
+ const pm = Number(status?.pm2_5 ?? status?.pm25 ?? status?.pm_2_5)
1216
+ await safeUpdate('pm2_5', { pm25: pm }, 'updatePM25')
1217
+ } catch (e: any) {
1218
+ this.debugLog(`Failed to apply PM2.5 for ${dev.deviceId}: ${e?.message ?? e}`)
1219
+ }
1220
+ }
1221
+ if (status?.rvcOperationalState !== undefined) {
1222
+ try {
1223
+ await safeUpdate('rvcOperationalState', { operationalState: Number(status.rvcOperationalState) }, 'updateOperationalState')
1224
+ } catch (e: any) {
1225
+ this.debugLog(`Failed to apply rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`)
1226
+ }
1227
+ }
1228
+ } catch (e: any) {
1229
+ this.debugLog(`applyStatusToAccessory top-level failure for ${dev.deviceId}: ${e?.message ?? e}`)
1230
+ }
1231
+ }
1232
+
94
1233
  /**
95
1234
  * Required for DynamicPlatformPlugin
96
1235
  * Called when homebridge restores cached accessories from disk at startup
@@ -108,7 +1247,7 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
108
1247
  * any custom data you stored when the accessory was originally registered.
109
1248
  */
110
1249
  configureMatterAccessory(accessory: SerializedMatterAccessory) {
111
- this.log.debug('Loading cached Matter accessory:', accessory.displayName)
1250
+ this.debugLog('Loading cached Matter accessory:', accessory.displayName)
112
1251
  this.matterAccessories.set(accessory.uuid, accessory)
113
1252
  }
114
1253
 
@@ -116,26 +1255,91 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
116
1255
  * Register all Matter accessories
117
1256
  */
118
1257
  private async registerMatterAccessories() {
119
- this.log.info('═'.repeat(80))
120
- this.log.info('Homebridge Matter Plugin')
121
- this.log.info('═'.repeat(80))
1258
+ this.debugLog('═'.repeat(80))
1259
+ this.infoLog('Homebridge Matter Plugin')
1260
+ this.debugLog('═'.repeat(80))
122
1261
 
123
1262
  // Remove accessories that are disabled in config
124
1263
  await this.removeDisabledAccessories()
125
1264
 
126
- // Register devices by Matter specification sections
127
- await this.registerSection4Lighting()
128
- await this.registerSection5SmartPlugs()
129
- await this.registerSection6Switches()
130
- await this.registerSection7Sensors()
131
- await this.registerSection8Closure()
132
- await this.registerSection9HVAC()
133
- await this.registerSection12Robotic()
134
- await this.registerCustomDevices()
135
-
136
- this.log.info('═'.repeat(80))
137
- this.log.info('Finished registering Matter accessories')
138
- this.log.info('═'.repeat(80))
1265
+ // If we discovered real SwitchBot devices via OpenAPI, map and register them
1266
+ if (this.discoveredDevices && this.discoveredDevices.length > 0) {
1267
+ this.infoLog(`Registering ${this.discoveredDevices.length} discovered SwitchBot device(s) as Matter accessories`)
1268
+
1269
+ // Merge device config (deviceConfig per deviceType and per-device overrides) to match HAP behavior
1270
+ const devicesToProcess = await this.mergeDiscoveredDevices(this.discoveredDevices)
1271
+
1272
+ // We'll separate discovered devices into two buckets:
1273
+ // - platformAccessories: accessories that will be hosted under the plugin's Matter bridge
1274
+ // - roboticAccessories: robot vacuum devices which require standalone commissioning behaviour
1275
+ const platformAccessories: Array<MatterAccessory<Record<string, unknown>>> = []
1276
+ const roboticAccessories: Array<MatterAccessory<Record<string, unknown>>> = []
1277
+
1278
+ // Known robot vacuum deviceType names (matches mapping in createAccessoryFromDevice)
1279
+ const robotTypes = new Set([
1280
+ 'K10+',
1281
+ 'K10+ Pro',
1282
+ 'WoSweeper',
1283
+ 'WoSweeperMini',
1284
+ 'Robot Vacuum Cleaner S1',
1285
+ 'Robot Vacuum Cleaner S1 Plus',
1286
+ 'Robot Vacuum Cleaner S10',
1287
+ 'Robot Vacuum Cleaner S1 Pro',
1288
+ 'Robot Vacuum Cleaner S1 Mini',
1289
+ ])
1290
+
1291
+ for (const dev of devicesToProcess) {
1292
+ try {
1293
+ const acc = await this.createAccessoryFromDevice(dev)
1294
+ if (!acc) {
1295
+ continue
1296
+ }
1297
+ if (robotTypes.has(dev.deviceType ?? '')) {
1298
+ roboticAccessories.push(acc)
1299
+ } else {
1300
+ platformAccessories.push(acc)
1301
+ }
1302
+ } catch (e: any) {
1303
+ this.errorLog(`Failed to create Matter accessory for ${dev.deviceId}: ${e?.message ?? e}`)
1304
+ }
1305
+ }
1306
+
1307
+ // Register platform-hosted accessories (most devices)
1308
+ if (platformAccessories.length > 0) {
1309
+ this.infoLog(`✓ Registered ${platformAccessories.length} discovered platform-hosted device(s)`)
1310
+ for (const acc of platformAccessories) {
1311
+ this.infoLog(` - ${acc.displayName}`)
1312
+ }
1313
+ await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, platformAccessories)
1314
+ }
1315
+
1316
+ // Register robotic accessories (robot vacuums) separately so they can be
1317
+ // commissioned in the way Apple Home expects (these devices often require
1318
+ // standalone commissioning flow). We still call registerPlatformAccessories
1319
+ // because the accessory implementations manage their commissioning behavior.
1320
+ if (roboticAccessories.length > 0) {
1321
+ this.infoLog(`✓ Registered ${roboticAccessories.length} discovered robot vacuum device(s)`)
1322
+ for (const acc of roboticAccessories) {
1323
+ this.infoLog(` - ${acc.displayName} (standalone for Apple Home compatibility)`)
1324
+ }
1325
+ await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, roboticAccessories)
1326
+ }
1327
+
1328
+ // Debug/info: how many discovered vs example accessories were registered.
1329
+ // Example accessories are disabled — we intentionally do NOT register them.
1330
+ const discoveredRegistered = platformAccessories.length + roboticAccessories.length
1331
+ const exampleRegistered = 0
1332
+ this.debugLog(`Discovered accessories registered: ${discoveredRegistered}; Example accessories registered: ${exampleRegistered}`)
1333
+
1334
+ return
1335
+ }
1336
+
1337
+ // If no discovered devices are available, do not register example/demo accessories.
1338
+ this.infoLog('No discovered SwitchBot devices found; not registering example Matter accessories by default.')
1339
+
1340
+ this.debugLog('═'.repeat(80))
1341
+ this.debugLog('Finished registering Matter accessories')
1342
+ this.debugLog('═'.repeat(80))
139
1343
  }
140
1344
 
141
1345
  /**
@@ -170,7 +1374,16 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
170
1374
  if (enabled === false) {
171
1375
  const existingAccessory = this.matterAccessories.get(uuid)
172
1376
  if (existingAccessory) {
173
- this.log.info(`Removing accessory '${name}' (disabled in config)`)
1377
+ this.infoLog(`Removing accessory '${name}' (disabled in config)`)
1378
+ // Attempt to clear any per-device resources (timers, BLE handlers, instances)
1379
+ try {
1380
+ const deviceId = (existingAccessory as any)?.context?.deviceId
1381
+ if (deviceId) {
1382
+ this.clearDeviceResources(deviceId)
1383
+ }
1384
+ } catch (e: any) {
1385
+ this.debugLog(`Failed to clear resources for disabled accessory ${name}: ${e?.message ?? e}`)
1386
+ }
174
1387
  await this.api.matter.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory as unknown as MatterAccessory])
175
1388
  this.matterAccessories.delete(uuid)
176
1389
  }
@@ -182,9 +1395,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
182
1395
  * Section 4: Lighting Devices (Matter Spec § 4)
183
1396
  */
184
1397
  private async registerSection4Lighting() {
185
- this.log.info('═'.repeat(80))
186
- this.log.info('Section 4: Lighting Devices (Matter Spec § 4)')
187
- this.log.info('═'.repeat(80))
1398
+ this.debugLog('═'.repeat(80))
1399
+ this.infoLog('Section 4: Lighting Devices (Matter Spec § 4)')
1400
+ this.debugLog('═'.repeat(80))
188
1401
 
189
1402
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
190
1403
 
@@ -219,9 +1432,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
219
1432
  }
220
1433
 
221
1434
  if (accessories.length > 0) {
222
- this.log.info(`✓ Registered ${accessories.length} lighting device(s)`)
1435
+ this.infoLog(`✓ Registered ${accessories.length} lighting device(s)`)
223
1436
  for (const acc of accessories) {
224
- this.log.info(` - ${acc.displayName}`)
1437
+ this.infoLog(` - ${acc.displayName}`)
225
1438
  }
226
1439
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
227
1440
  }
@@ -231,9 +1444,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
231
1444
  * Section 5: Smart Plugs/Actuators (Matter Spec § 5)
232
1445
  */
233
1446
  private async registerSection5SmartPlugs() {
234
- this.log.info('═'.repeat(80))
235
- this.log.info('Section 5: Smart Plugs/Actuators (Matter Spec § 5)')
236
- this.log.info('═'.repeat(80))
1447
+ this.debugLog('═'.repeat(80))
1448
+ this.infoLog('Section 5: Smart Plugs/Actuators (Matter Spec § 5)')
1449
+ this.debugLog('═'.repeat(80))
237
1450
 
238
1451
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
239
1452
 
@@ -244,9 +1457,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
244
1457
  }
245
1458
 
246
1459
  if (accessories.length > 0) {
247
- this.log.info(`✓ Registered ${accessories.length} smart plug/actuator device(s)`)
1460
+ this.infoLog(`✓ Registered ${accessories.length} smart plug/actuator device(s)`)
248
1461
  for (const acc of accessories) {
249
- this.log.info(` - ${acc.displayName}`)
1462
+ this.infoLog(` - ${acc.displayName}`)
250
1463
  }
251
1464
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
252
1465
  }
@@ -256,9 +1469,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
256
1469
  * Section 6: Switches & Controllers (Matter Spec § 6)
257
1470
  */
258
1471
  private async registerSection6Switches() {
259
- this.log.info('═'.repeat(80))
260
- this.log.info('Section 6: Switches & Controllers (Matter Spec § 6)')
261
- this.log.info('═'.repeat(80))
1472
+ this.debugLog('═'.repeat(80))
1473
+ this.infoLog('Section 6: Switches & Controllers (Matter Spec § 6)')
1474
+ this.debugLog('═'.repeat(80))
262
1475
 
263
1476
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
264
1477
 
@@ -269,9 +1482,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
269
1482
  }
270
1483
 
271
1484
  if (accessories.length > 0) {
272
- this.log.info(`✓ Registered ${accessories.length} switch/controller device(s)`)
1485
+ this.infoLog(`✓ Registered ${accessories.length} switch/controller device(s)`)
273
1486
  for (const acc of accessories) {
274
- this.log.info(` - ${acc.displayName}`)
1487
+ this.infoLog(` - ${acc.displayName}`)
275
1488
  }
276
1489
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
277
1490
  }
@@ -281,9 +1494,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
281
1494
  * Section 7: Sensors (Matter Spec § 7)
282
1495
  */
283
1496
  private async registerSection7Sensors() {
284
- this.log.info('═'.repeat(80))
285
- this.log.info('Section 7: Sensors (Matter Spec § 7)')
286
- this.log.info('═'.repeat(80))
1497
+ this.debugLog('═'.repeat(80))
1498
+ this.infoLog('Section 7: Sensors (Matter Spec § 7)')
1499
+ this.debugLog('═'.repeat(80))
287
1500
 
288
1501
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
289
1502
 
@@ -330,9 +1543,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
330
1543
  }
331
1544
 
332
1545
  if (accessories.length > 0) {
333
- this.log.info(`✓ Registered ${accessories.length} sensor device(s)`)
1546
+ this.infoLog(`✓ Registered ${accessories.length} sensor device(s)`)
334
1547
  for (const acc of accessories) {
335
- this.log.info(` - ${acc.displayName}`)
1548
+ this.infoLog(` - ${acc.displayName}`)
336
1549
  }
337
1550
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
338
1551
  }
@@ -342,9 +1555,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
342
1555
  * Section 8: Closure Devices (Matter Spec § 8)
343
1556
  */
344
1557
  private async registerSection8Closure() {
345
- this.log.info('═'.repeat(80))
346
- this.log.info('Section 8: Closure Devices (Matter Spec § 8)')
347
- this.log.info('═'.repeat(80))
1558
+ this.debugLog('═'.repeat(80))
1559
+ this.infoLog('Section 8: Closure Devices (Matter Spec § 8)')
1560
+ this.debugLog('═'.repeat(80))
348
1561
 
349
1562
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
350
1563
 
@@ -367,9 +1580,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
367
1580
  }
368
1581
 
369
1582
  if (accessories.length > 0) {
370
- this.log.info(`✓ Registered ${accessories.length} closure device(s)`)
1583
+ this.infoLog(`✓ Registered ${accessories.length} closure device(s)`)
371
1584
  for (const acc of accessories) {
372
- this.log.info(` - ${acc.displayName}`)
1585
+ this.infoLog(` - ${acc.displayName}`)
373
1586
  }
374
1587
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
375
1588
  }
@@ -379,9 +1592,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
379
1592
  * Section 9: HVAC (Matter Spec § 9)
380
1593
  */
381
1594
  private async registerSection9HVAC() {
382
- this.log.info('═'.repeat(80))
383
- this.log.info('Section 9: HVAC (Matter Spec § 9)')
384
- this.log.info('═'.repeat(80))
1595
+ this.debugLog('═'.repeat(80))
1596
+ this.infoLog('Section 9: HVAC (Matter Spec § 9)')
1597
+ this.debugLog('═'.repeat(80))
385
1598
 
386
1599
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
387
1600
 
@@ -398,9 +1611,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
398
1611
  }
399
1612
 
400
1613
  if (accessories.length > 0) {
401
- this.log.info(`✓ Registered ${accessories.length} HVAC device(s)`)
1614
+ this.infoLog(`✓ Registered ${accessories.length} HVAC device(s)`)
402
1615
  for (const acc of accessories) {
403
- this.log.info(` - ${acc.displayName}`)
1616
+ this.infoLog(` - ${acc.displayName}`)
404
1617
  }
405
1618
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
406
1619
  }
@@ -413,9 +1626,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
413
1626
  * Use those codes to pair the vacuum as a separate bridge in your Home app.
414
1627
  */
415
1628
  private async registerSection12Robotic() {
416
- this.log.info('═'.repeat(80))
417
- this.log.info('Section 12: Robotic Devices (Matter Spec § 12)')
418
- this.log.info('═'.repeat(80))
1629
+ this.debugLog('═'.repeat(80))
1630
+ this.infoLog('Section 12: Robotic Devices (Matter Spec § 12)')
1631
+ this.debugLog('═'.repeat(80))
419
1632
 
420
1633
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
421
1634
 
@@ -426,9 +1639,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
426
1639
  }
427
1640
 
428
1641
  if (accessories.length > 0) {
429
- this.log.info(`✓ Registered ${accessories.length} robot vacuum device(s)`)
1642
+ this.infoLog(`✓ Registered ${accessories.length} robot vacuum device(s)`)
430
1643
  for (const acc of accessories) {
431
- this.log.info(` - ${acc.displayName} (standalone for Apple Home compatibility)`)
1644
+ this.infoLog(` - ${acc.displayName} (standalone for Apple Home compatibility)`)
432
1645
  }
433
1646
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
434
1647
  }
@@ -442,9 +1655,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
442
1655
  * like managing multiple logical components within a single device.
443
1656
  */
444
1657
  private async registerCustomDevices() {
445
- this.log.info('═'.repeat(80))
446
- this.log.info('Custom Devices')
447
- this.log.info('═'.repeat(80))
1658
+ this.debugLog('═'.repeat(80))
1659
+ this.infoLog('Custom Devices')
1660
+ this.debugLog('═'.repeat(80))
448
1661
 
449
1662
  const accessories: Array<MatterAccessory<Record<string, unknown>>> = []
450
1663
 
@@ -455,9 +1668,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
455
1668
  }
456
1669
 
457
1670
  if (accessories.length > 0) {
458
- this.log.info(`✓ Registered ${accessories.length} custom device(s)`)
1671
+ this.infoLog(`✓ Registered ${accessories.length} custom device(s)`)
459
1672
  for (const acc of accessories) {
460
- this.log.info(` - ${acc.displayName}`)
1673
+ this.infoLog(` - ${acc.displayName}`)
461
1674
  }
462
1675
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
463
1676
  }