@switchbot/homebridge-switchbot 5.0.0-beta.4 → 5.0.0-beta.40

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