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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,7 +39,7 @@ import {
39
39
  WindowBlindAccessory,
40
40
  } from './devices-matter/index.js'
41
41
  import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'
42
- import { cleanDeviceConfig, createPlatformLogger, formatDeviceIdAsMac, hs2rgb, makeBLESender, makeOpenAPISender, rgb2hs, sleep } from './utils.js'
42
+ import { ApiRequestTracker, cleanDeviceConfig, createPlatformLogger, formatDeviceIdAsMac, hs2rgb, makeBLESender, makeOpenAPISender, rgb2hs, sleep } from './utils.js'
43
43
 
44
44
  export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
45
45
  // Track restored HAP cached accessories (required for DynamicPlatformPlugin)
@@ -57,6 +57,12 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
57
57
  private accessoryInstances: Map<string, any> = new Map()
58
58
  // Refresh timers keyed by normalized deviceId
59
59
  private refreshTimers: Map<string, NodeJS.Timeout> = new Map()
60
+ // Platform-level refresh timer for batch status updates
61
+ private platformRefreshTimer?: NodeJS.Timeout
62
+ // Device status cache from last refresh
63
+ private deviceStatusCache: Map<string, any> = new Map()
64
+ // Devices that have explicit per-device refresh timers (normalized deviceId)
65
+ private perDeviceRefreshSet: Set<string> = new Set()
60
66
  // BLE event handlers keyed by device MAC (formatted)
61
67
  private bleEventHandler: { [x: string]: (context: any) => void } = {}
62
68
  // MQTT and Webhook properties (mirror HAP behavior so Matter platform can
@@ -68,6 +74,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
68
74
  // Use same shape as HAP platform: string values like 'debug', 'debugMode', 'standard', or 'none'
69
75
  private platformLogging?: string
70
76
 
77
+ // API request tracking (persistent across restarts)
78
+ private apiTracker?: ApiRequestTracker
79
+
71
80
  // Platform-provided logging helpers (attached in constructor)
72
81
  infoLog!: (...args: any[]) => void
73
82
  successLog!: (...args: any[]) => void
@@ -173,6 +182,14 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
173
182
  this.errorLog('Failed to initialize SwitchBot OpenAPI:', e?.message ?? e)
174
183
  }
175
184
 
185
+ // Initialize API request tracking
186
+ try {
187
+ this.apiTracker = new ApiRequestTracker(this.api, this.log, 'SwitchBot Matter')
188
+ this.apiTracker.startHourlyLogging()
189
+ } catch (e: any) {
190
+ this.errorLog('Failed to initialize API request tracking:', e?.message ?? e)
191
+ }
192
+
176
193
  try {
177
194
  this.switchBotBLE = new SwitchBotBLE()
178
195
  if (!this.config.options?.disableLogsforBLE && this.switchBotBLE?.on) {
@@ -211,6 +228,12 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
211
228
  this.api.on('shutdown', async () => {
212
229
  try {
213
230
  this.infoLog('Homebridge shutting down: clearing refresh timers and BLE handlers')
231
+
232
+ // Stop API tracking hourly logging
233
+ if (this.apiTracker) {
234
+ this.apiTracker.stopHourlyLogging()
235
+ }
236
+
214
237
  // Clear all refresh timers
215
238
  for (const [nid, t] of Array.from(this.refreshTimers.entries())) {
216
239
  try {
@@ -221,6 +244,16 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
221
244
  this.refreshTimers.delete(nid)
222
245
  }
223
246
 
247
+ // Clear platform-level refresh timer
248
+ try {
249
+ if (this.platformRefreshTimer) {
250
+ clearInterval(this.platformRefreshTimer)
251
+ this.platformRefreshTimer = undefined
252
+ }
253
+ } catch (e: any) {
254
+ this.debugLog(`Failed to clear platform refresh timer: ${e?.message ?? e}`)
255
+ }
256
+
224
257
  // Clear accessory instances registry
225
258
  try {
226
259
  this.accessoryInstances.clear()
@@ -290,6 +323,14 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
290
323
  return (deviceId ?? '').toUpperCase().replace(/[^A-Z0-9]+/g, '')
291
324
  }
292
325
 
326
+ /** Determine the platform-level batch interval in seconds */
327
+ private getPlatformBatchInterval(): number {
328
+ const opt = this.config.options
329
+ const val = opt?.matterBatchRefreshRate ?? opt?.refreshRate ?? 300
330
+ const n = Number(val)
331
+ return Number.isFinite(n) && n > 0 ? n : 300
332
+ }
333
+
293
334
  /**
294
335
  * Clear per-device resources: refresh timers, accessory instance registry, BLE handlers
295
336
  */
@@ -720,7 +761,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
720
761
 
721
762
  // Brightness
722
763
  if (parsed.brightness !== undefined) {
723
- const level = Math.round((Number(parsed.brightness) / 100) * 254)
764
+ const rawLevel = Math.round((Number(parsed.brightness) / 100) * 254)
765
+ const level = Math.max(0, Math.min(254, rawLevel))
766
+ this.debugLog(`[BLE Brightness Debug] Device ${dev.deviceId}: rawBrightness=${parsed.brightness}, calculated=${rawLevel}, clamped=${level}`)
724
767
  await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.LevelControl, { currentLevel: level })
725
768
  }
726
769
 
@@ -728,31 +771,44 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
728
771
  if (parsed.color !== undefined) {
729
772
  const { r, g, b } = parsed.color
730
773
  const [h, s] = rgb2hs(r, g, b)
731
- await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.ColorControl, { currentHue: Math.round((h / 360) * 254), currentSaturation: Math.round((s / 100) * 254) })
774
+ const rawHue = Math.round((h / 360) * 254)
775
+ const rawSat = Math.round((s / 100) * 254)
776
+ const hue = Math.max(0, Math.min(254, rawHue))
777
+ const sat = Math.max(0, Math.min(254, rawSat))
778
+ this.debugLog(`[BLE Color Debug] Device ${dev.deviceId}: RGB=[${r},${g},${b}], HS=[${h},${s}], Matter=[${rawHue},${rawSat}], clamped=[${hue},${sat}]`)
779
+ await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.ColorControl, { currentHue: hue, currentSaturation: sat })
732
780
  }
733
781
 
734
782
  // Battery -> powerSource cluster (common mapping)
735
783
  if (parsed.battery !== undefined) {
736
- try {
737
- const percentage = Number(parsed.battery)
738
- const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)))
739
- let batChargeLevel = 0
740
- if (percentage < 20) {
741
- batChargeLevel = 2
742
- } else if (percentage < 40) {
743
- batChargeLevel = 1
744
- }
784
+ // Skip battery updates for device types that don't support PowerSource cluster
785
+ const deviceType = String(dev?.deviceType ?? '')
786
+ const unsupportedTypes = ['Curtain', 'Curtain2', 'Curtain3', 'Curtain 2', 'Blind Tilt']
787
+
788
+ if (unsupportedTypes.includes(deviceType)) {
789
+ this.debugLog(`Device ${dev.deviceId} type ${deviceType} does not support PowerSource cluster, skipping BLE battery update`)
790
+ } else {
745
791
  try {
746
- await this.api.matter.updateAccessoryState(uuidLocal, 'powerSource', { batPercentRemaining, batChargeLevel })
747
- } catch (updateError: any) {
748
- // Silently skip if powerSource cluster doesn't exist on this device
749
- const msg = String(updateError?.message ?? updateError)
750
- if (!msg.includes('does not exist') && !msg.includes('not found')) {
751
- throw updateError
792
+ const percentage = Number(parsed.battery)
793
+ const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)))
794
+ let batChargeLevel = 0
795
+ if (percentage < 20) {
796
+ batChargeLevel = 2
797
+ } else if (percentage < 40) {
798
+ batChargeLevel = 1
752
799
  }
800
+ try {
801
+ await this.api.matter.updateAccessoryState(uuidLocal, 'powerSource', { batPercentRemaining, batChargeLevel })
802
+ } catch (updateError: any) {
803
+ // Silently skip if powerSource cluster doesn't exist on this device
804
+ const msg = String(updateError?.message ?? updateError)
805
+ if (!msg.includes('does not exist') && !msg.includes('not found')) {
806
+ throw updateError
807
+ }
808
+ }
809
+ } catch (e: any) {
810
+ this.debugLog(`Failed to update battery state for ${dev.deviceId}: ${e?.message ?? e}`)
753
811
  }
754
- } catch (e: any) {
755
- this.debugLog(`Failed to update battery state for ${dev.deviceId}: ${e?.message ?? e}`)
756
812
  }
757
813
  }
758
814
 
@@ -880,6 +936,7 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
880
936
  return
881
937
  }
882
938
  try {
939
+ this.apiTracker?.track()
883
940
  const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret)
884
941
  const respAny: any = response
885
942
  const body = respAny?.body ?? respAny
@@ -914,12 +971,14 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
914
971
  this.refreshTimers.delete(nid)
915
972
  }
916
973
 
917
- const refreshRateSec = dev.refreshRate ?? this.config.options?.refreshRate ?? 300
918
- if (this.switchBotAPI && refreshRateSec && Number(refreshRateSec) > 0) {
974
+ const platformInterval = this.getPlatformBatchInterval()
975
+ const hasDeviceInterval = typeof dev.refreshRate === 'number' && Number(dev.refreshRate) > 0
976
+ if (this.switchBotAPI && (hasDeviceInterval || platformInterval > 0)) {
919
977
  // Immediate one-shot to populate initial state
920
978
  ;(async () => {
921
979
  try {
922
980
  this.infoLog(`Performing initial OpenAPI refresh for ${dev.deviceId}`)
981
+ this.apiTracker?.track()
923
982
  const { response, statusCode } = await this.switchBotAPI!.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret)
924
983
  const respAny: any = response
925
984
  const body = respAny?.body ?? respAny
@@ -941,30 +1000,23 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
941
1000
  }
942
1001
  })()
943
1002
 
944
- const timer = setInterval(async () => {
945
- try {
946
- this.debugLog(`Performing periodic OpenAPI refresh for ${dev.deviceId}`)
947
- const { response, statusCode } = await this.switchBotAPI!.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret)
948
- const respAny: any = response
949
- const body = respAny?.body ?? respAny
1003
+ if (hasDeviceInterval) {
1004
+ // Create a per-device timer and exclude it from batch
1005
+ const interval = Number(dev.refreshRate)
1006
+ this.perDeviceRefreshSet.add(nid)
1007
+ const timer = setInterval(async () => {
950
1008
  try {
951
- const s = JSON.stringify(body)
952
- this.debugLog(`Periodic OpenAPI refresh for ${dev.deviceId} returned statusCode=${statusCode} body=${s}`)
953
- } catch (e) {
954
- this.debugLog(`Periodic OpenAPI refresh for ${dev.deviceId} returned statusCode=${statusCode} (body not stringifiable)`)
955
- }
956
- if (statusCode === 100 || statusCode === 200) {
957
- const status = body?.status ?? body
958
- await this.applyStatusToAccessory(uuid, dev, status)
959
- } else {
960
- this.debugLog(`Periodic OpenAPI refresh returned unexpected statusCode=${statusCode} for ${dev.deviceId}`)
1009
+ await this.refreshSingleDeviceWithRetry(dev)
1010
+ } catch (e: any) {
1011
+ this.debugLog(`Per-device refresh failed for ${dev.deviceId}: ${e?.message ?? e}`)
961
1012
  }
962
- } catch (e: any) {
963
- this.errorLog(`Periodic OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`)
964
- }
965
- }, Number(refreshRateSec) * 1000)
966
-
967
- this.refreshTimers.set(nid, timer)
1013
+ }, interval * 1000)
1014
+ this.refreshTimers.set(nid, timer)
1015
+ this.infoLog(`Started per-device refresh timer for ${dev.deviceId} at ${interval}s`)
1016
+ } else {
1017
+ // Start platform-level batched refresh timer (only once)
1018
+ this.startPlatformRefreshTimer(platformInterval)
1019
+ }
968
1020
  }
969
1021
  } catch (e: any) {
970
1022
  this.debugLog(`Failed to schedule refresh for ${dev.deviceId}: ${e?.message ?? e}`)
@@ -983,6 +1035,7 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
983
1035
  }
984
1036
 
985
1037
  try {
1038
+ this.apiTracker?.track()
986
1039
  const { response, statusCode } = await this.switchBotAPI.getDevices()
987
1040
  this.debugLog(`SwitchBot getDevices response status: ${statusCode}`)
988
1041
  if (statusCode === 100 || statusCode === 200) {
@@ -1082,6 +1135,7 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
1082
1135
  if (!this.switchBotAPI) {
1083
1136
  throw new Error('SwitchBot OpenAPI not initialized')
1084
1137
  }
1138
+ this.apiTracker?.track()
1085
1139
  const { response, statusCode } = await this.switchBotAPI.controlDevice(
1086
1140
  deviceObj.deviceId,
1087
1141
  bodyChange.command,
@@ -1330,10 +1384,19 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
1330
1384
  // Brightness
1331
1385
  if (status?.brightness !== undefined) {
1332
1386
  const rawBrightness = Number(status.brightness)
1333
- const level = Math.round((rawBrightness / 100) * 254)
1334
- const clampedLevel = Math.max(0, Math.min(254, level))
1335
- this.debugLog(`[Brightness Debug] Device ${dev.deviceId}: rawBrightness=${rawBrightness}, calculated=${level}, clamped=${clampedLevel}`)
1336
- await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: clampedLevel }, 'updateBrightness')
1387
+ const clampedBrightness = Math.max(0, Math.min(100, rawBrightness))
1388
+
1389
+ // If instance has updateBrightness method, it expects percentage (0-100)
1390
+ // Otherwise, updateAccessoryState expects Matter-scaled value (0-254)
1391
+ if (instance && typeof instance.updateBrightness === 'function') {
1392
+ this.debugLog(`[Brightness Debug] Device ${dev.deviceId}: calling updateBrightness with percent=${clampedBrightness}`)
1393
+ await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: clampedBrightness }, 'updateBrightness')
1394
+ } else {
1395
+ const level = Math.round((clampedBrightness / 100) * 254)
1396
+ const clampedLevel = Math.max(0, Math.min(254, level))
1397
+ this.debugLog(`[Brightness Debug] Device ${dev.deviceId}: calling updateAccessoryState with rawBrightness=${rawBrightness}, level=${clampedLevel}`)
1398
+ await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: clampedLevel })
1399
+ }
1337
1400
  }
1338
1401
 
1339
1402
  // Color
@@ -1358,29 +1421,48 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
1358
1421
  b = Number.parseInt(hex.substring(4, 6), 16)
1359
1422
  }
1360
1423
  const [h, s] = rgb2hs(r, g, b)
1361
- const hue = Math.round((h / 360) * 254)
1362
- const sat = Math.round((s / 100) * 254)
1363
- const clampedHue = Math.max(0, Math.min(254, hue))
1364
- const clampedSat = Math.max(0, Math.min(254, sat))
1365
- this.debugLog(`[Color Debug] Device ${dev.deviceId}: color="${color}", RGB=[${r},${g},${b}], HS=[${h},${s}], Matter=[${hue},${sat}], clamped=[${clampedHue},${clampedSat}]`)
1366
- await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: clampedHue, currentSaturation: clampedSat }, 'updateHueSaturation')
1424
+ const clampedH = Math.max(0, Math.min(360, h))
1425
+ const clampedS = Math.max(0, Math.min(100, s))
1426
+
1427
+ // If instance has updateHueSaturation method, it expects raw values (h: 0-360, s: 0-100)
1428
+ // Otherwise, updateAccessoryState expects Matter-scaled values (0-254)
1429
+ if (instance && typeof instance.updateHueSaturation === 'function') {
1430
+ this.debugLog(`[Color Debug] Device ${dev.deviceId}: calling updateHueSaturation with color="${color}", RGB=[${r},${g},${b}], HS=[${clampedH},${clampedS}]`)
1431
+ await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: clampedH, currentSaturation: clampedS }, 'updateHueSaturation')
1432
+ } else {
1433
+ const hue = Math.round((clampedH / 360) * 254)
1434
+ const sat = Math.round((clampedS / 100) * 254)
1435
+ const clampedHue = Math.max(0, Math.min(254, hue))
1436
+ const clampedSat = Math.max(0, Math.min(254, sat))
1437
+ this.debugLog(`[Color Debug] Device ${dev.deviceId}: calling updateAccessoryState with color="${color}", RGB=[${r},${g},${b}], HS=[${h},${s}], Matter=[${clampedHue},${clampedSat}]`)
1438
+ await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: clampedHue, currentSaturation: clampedSat })
1439
+ }
1367
1440
  }
1368
1441
 
1369
1442
  // Battery/powerSource (support many possible field names)
1443
+ // Note: Some device types like WindowBlind don't support PowerSource cluster
1370
1444
  if (status?.battery !== undefined || status?.batt !== undefined || status?.batteryLevel !== undefined || status?.batteryPercentage !== undefined || status?.battery_level !== undefined) {
1371
- try {
1372
- const percentage = Number(status?.battery ?? status?.batt ?? status?.batteryPercentage ?? status?.batteryLevel ?? status?.battery_level)
1373
- const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)))
1374
- let batChargeLevel = 0
1375
- if (percentage < 20) {
1376
- batChargeLevel = 2
1377
- } else if (percentage < 40) {
1378
- batChargeLevel = 1
1445
+ // Skip battery updates for device types that don't support PowerSource cluster
1446
+ const deviceType = String(status?.deviceType ?? dev?.deviceType ?? '')
1447
+ const unsupportedTypes = ['Curtain', 'Curtain2', 'Curtain3', 'Curtain 2', 'Blind Tilt']
1448
+
1449
+ if (unsupportedTypes.includes(deviceType)) {
1450
+ this.debugLog(`Device ${dev.deviceId} type ${deviceType} does not support PowerSource cluster, skipping battery update`)
1451
+ } else {
1452
+ try {
1453
+ const percentage = Number(status?.battery ?? status?.batt ?? status?.batteryPercentage ?? status?.batteryLevel ?? status?.battery_level)
1454
+ const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)))
1455
+ let batChargeLevel = 0
1456
+ if (percentage < 20) {
1457
+ batChargeLevel = 2
1458
+ } else if (percentage < 40) {
1459
+ batChargeLevel = 1
1460
+ }
1461
+ const powerCluster = (this.api.matter?.clusterNames && (this.api.matter.clusterNames as any).PowerSource) ? (this.api.matter.clusterNames as any).PowerSource : 'powerSource'
1462
+ await safeUpdate(powerCluster, { batPercentRemaining, batChargeLevel }, 'updateBatteryPercentage')
1463
+ } catch (e: any) {
1464
+ this.debugLog(`Failed to apply battery status for ${dev.deviceId}: ${e?.message ?? e}`)
1379
1465
  }
1380
- const powerCluster = (this.api.matter?.clusterNames && (this.api.matter.clusterNames as any).PowerSource) ? (this.api.matter.clusterNames as any).PowerSource : 'powerSource'
1381
- await safeUpdate(powerCluster, { batPercentRemaining, batChargeLevel }, 'updateBatteryPercentage')
1382
- } catch (e: any) {
1383
- this.debugLog(`Failed to apply battery status for ${dev.deviceId}: ${e?.message ?? e}`)
1384
1466
  }
1385
1467
  }
1386
1468
 
@@ -2042,4 +2124,118 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
2042
2124
  await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories)
2043
2125
  }
2044
2126
  }
2127
+
2128
+ /**
2129
+ * Start platform-level refresh timer to batch all device status updates
2130
+ */
2131
+ private startPlatformRefreshTimer(refreshRateSec: number): void {
2132
+ // Only create timer once
2133
+ if (this.platformRefreshTimer) {
2134
+ return
2135
+ }
2136
+
2137
+ this.debugLog(`Starting platform-level refresh timer with interval ${refreshRateSec}s`)
2138
+ this.platformRefreshTimer = setInterval(async () => {
2139
+ await this.batchRefreshAllDevices()
2140
+ }, Number(refreshRateSec) * 1000)
2141
+ }
2142
+
2143
+ /**
2144
+ * Batch refresh all devices - still makes individual API calls but batches them together
2145
+ * Note: SwitchBot API doesn't support true batch status calls, but we can parallelize them
2146
+ */
2147
+ private async batchRefreshAllDevices(): Promise<void> {
2148
+ if (!this.switchBotAPI) {
2149
+ return
2150
+ }
2151
+
2152
+ this.debugLog('Performing batched periodic OpenAPI refresh for all devices')
2153
+
2154
+ // Build list from registered accessory instances (uuid) and discovered devices
2155
+ const devicesToRefresh: Array<{ uuid: string, dev: device }> = []
2156
+ try {
2157
+ for (const [nid, instance] of this.accessoryInstances.entries()) {
2158
+ const uuid = (instance as any)?.uuid as string | undefined
2159
+ if (!uuid) {
2160
+ continue
2161
+ }
2162
+ const dev = this.discoveredDevices.find(d => this.normalizeDeviceId(d.deviceId) === nid)
2163
+ if (dev && !this.perDeviceRefreshSet.has(nid)) {
2164
+ devicesToRefresh.push({ uuid, dev })
2165
+ }
2166
+ }
2167
+ } catch (e: any) {
2168
+ this.errorLog(`Failed to enumerate devices for batch refresh: ${e?.message ?? e}`)
2169
+ return
2170
+ }
2171
+
2172
+ if (devicesToRefresh.length === 0) {
2173
+ this.debugLog('No devices to refresh')
2174
+ return
2175
+ }
2176
+
2177
+ this.debugLog(`Refreshing ${devicesToRefresh.length} devices in parallel batch`)
2178
+
2179
+ const concurrency = Number(this.config.options?.matterBatchConcurrency ?? 5)
2180
+ await this.runWithConcurrency(devicesToRefresh, async ({ uuid, dev }) => {
2181
+ try {
2182
+ const status = await this.refreshSingleDeviceWithRetry(dev)
2183
+ if (status) {
2184
+ await this.applyStatusToAccessory(uuid, dev as any, status)
2185
+ }
2186
+ } catch (e: any) {
2187
+ this.errorLog(`Periodic OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`)
2188
+ }
2189
+ }, Number.isFinite(concurrency) && concurrency > 0 ? concurrency : 5)
2190
+ this.debugLog(`Batch refresh completed for ${devicesToRefresh.length} devices`)
2191
+ }
2192
+
2193
+ /** Refresh a single device with retry and backoff; returns status object if successful */
2194
+ private async refreshSingleDeviceWithRetry(dev: device, retries = 3, baseDelayMs = 500): Promise<any | null> {
2195
+ const deviceId = dev.deviceId
2196
+ let attempt = 0
2197
+ while (attempt <= retries) {
2198
+ try {
2199
+ this.apiTracker?.track()
2200
+ const { response, statusCode } = await this.switchBotAPI!.getDeviceStatus(deviceId, this.config.credentials?.token, this.config.credentials?.secret)
2201
+ const respAny: any = response
2202
+ const body = respAny?.body ?? respAny
2203
+ if (statusCode === 100 || statusCode === 200) {
2204
+ const status = body?.status ?? body
2205
+ this.deviceStatusCache.set(this.normalizeDeviceId(deviceId), { status, timestamp: Date.now() })
2206
+ this.debugLog(`OpenAPI refresh succeeded for ${deviceId} (attempt ${attempt + 1})`)
2207
+ return status
2208
+ }
2209
+ this.debugLog(`OpenAPI refresh unexpected statusCode=${statusCode} for ${deviceId} (attempt ${attempt + 1})`)
2210
+ } catch (e: any) {
2211
+ this.debugLog(`OpenAPI refresh error for ${deviceId} (attempt ${attempt + 1}): ${e?.message ?? e}`)
2212
+ }
2213
+ // backoff before next retry if any left
2214
+ attempt++
2215
+ if (attempt <= retries) {
2216
+ const delay = baseDelayMs * (2 ** (attempt - 1))
2217
+ await sleep(delay)
2218
+ }
2219
+ }
2220
+ return null
2221
+ }
2222
+
2223
+ /** Simple concurrency limiter for an array of items */
2224
+ private async runWithConcurrency<T>(items: T[], worker: (item: T) => Promise<void>, concurrency: number): Promise<void> {
2225
+ const queue = items.slice()
2226
+ const workers: Promise<void>[] = []
2227
+ const runNext = async (): Promise<void> => {
2228
+ const item = queue.shift()
2229
+ if (!item) {
2230
+ return
2231
+ }
2232
+ await worker(item)
2233
+ return runNext()
2234
+ }
2235
+ const pool = Math.min(concurrency, Math.max(1, items.length))
2236
+ for (let i = 0; i < pool; i++) {
2237
+ workers.push(runNext())
2238
+ }
2239
+ await Promise.all(workers)
2240
+ }
2045
2241
  }
package/src/settings.ts CHANGED
@@ -61,6 +61,9 @@ export interface options {
61
61
  updateRate?: number
62
62
  pushRate?: number
63
63
  logging?: string
64
+ // Matter platform batch refresh options
65
+ matterBatchRefreshRate?: number
66
+ matterBatchConcurrency?: number
64
67
  };
65
68
 
66
69
  export type devicesConfig = botConfig | relaySwitch1Config | relaySwitch1PMConfig | meterConfig | meterProConfig | indoorOutdoorSensorConfig | humidifierConfig | curtainConfig | blindTiltConfig | contactConfig | motionConfig | waterDetectorConfig | plugConfig | colorBulbConfig | stripLightConfig | ceilingLightConfig | lockConfig | hubConfig
package/src/utils.ts CHANGED
@@ -1,12 +1,15 @@
1
- /* Copyright(C) 2017-2024, donavanbecker (https://github.com/donavanbecker). All rights reserved.
2
- *
3
- * util.ts: @switchbot/homebridge-switchbot platform class.
4
- */
5
1
  import type { API, Logging } from 'homebridge'
6
2
  import type { blindTilt, curtain, curtain3, device } from 'node-switchbot'
7
3
 
8
4
  import type { devicesConfig } from './settings.js'
9
5
 
6
+ /* Copyright(C) 2017-2024, donavanbecker (https://github.com/donavanbecker). All rights reserved.
7
+ *
8
+ * util.ts: @switchbot/homebridge-switchbot platform class.
9
+ */
10
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+
10
13
  export enum BlindTiltMappingMode {
11
14
  OnlyUp = 'only_up',
12
15
  OnlyDown = 'only_down',
@@ -971,3 +974,133 @@ export function createPlatformProxy(HAPCtor: any, MatterCtor: any) {
971
974
  }
972
975
  }
973
976
  }
977
+
978
+ /**
979
+ * API Request Tracker - Persistent tracking of SwitchBot API calls
980
+ * Tracks requests per day with automatic midnight rollover
981
+ */
982
+ export class ApiRequestTracker {
983
+ private count = 0
984
+ private date = ''
985
+ private statsFile = ''
986
+ private hourlyTimer?: NodeJS.Timeout
987
+ private log: Logging
988
+
989
+ constructor(api: API, log: Logging, pluginName = 'SwitchBot') {
990
+ this.log = log
991
+ this.statsFile = join(api.user.storagePath(), `${pluginName.toLowerCase()}-api-stats.json`)
992
+ this.load()
993
+ }
994
+
995
+ /**
996
+ * Load API request statistics from persistent storage
997
+ */
998
+ private load(): void {
999
+ try {
1000
+ const today = new Date().toISOString().split('T')[0]
1001
+
1002
+ if (existsSync(this.statsFile)) {
1003
+ const data = JSON.parse(readFileSync(this.statsFile, 'utf8'))
1004
+
1005
+ // If it's a new day, reset the counter
1006
+ if (data.date === today) {
1007
+ this.count = data.count || 0
1008
+ this.date = data.date
1009
+ this.log.warn?.(`[API Stats] Loaded: ${this.count} requests today (${today})`)
1010
+ } else {
1011
+ this.log.error?.(`[API Stats] New day detected. Previous: ${data.count || 0} requests on ${data.date}`)
1012
+ this.count = 0
1013
+ this.date = today
1014
+ this.save()
1015
+ }
1016
+ } else {
1017
+ this.log.debug?.('[API Stats] No existing stats file, starting fresh')
1018
+ this.count = 0
1019
+ this.date = today
1020
+ this.save()
1021
+ }
1022
+ } catch (e: any) {
1023
+ this.log.error?.(`[API Stats] Failed to load stats: ${e?.message ?? e}`)
1024
+ this.count = 0
1025
+ this.date = new Date().toISOString().split('T')[0]
1026
+ }
1027
+ }
1028
+
1029
+ /**
1030
+ * Save API request statistics to persistent storage
1031
+ */
1032
+ private save(): void {
1033
+ try {
1034
+ const data = {
1035
+ date: this.date,
1036
+ count: this.count,
1037
+ lastUpdated: new Date().toISOString(),
1038
+ }
1039
+ writeFileSync(this.statsFile, JSON.stringify(data, null, 2), 'utf8')
1040
+ } catch (e: any) {
1041
+ this.log.debug?.(`[API Stats] Failed to save stats: ${e?.message ?? e}`)
1042
+ }
1043
+ }
1044
+
1045
+ /**
1046
+ * Increment API request counter and save
1047
+ */
1048
+ public track(): void {
1049
+ const today = new Date().toISOString().split('T')[0]
1050
+
1051
+ // Reset counter if it's a new day
1052
+ if (this.date !== today) {
1053
+ this.log.debug?.(`[API Stats] Day rollover: ${this.count} requests on ${this.date}`)
1054
+ this.count = 0
1055
+ this.date = today
1056
+ }
1057
+
1058
+ this.count++
1059
+ this.save()
1060
+ }
1061
+
1062
+ /**
1063
+ * Start hourly logging of API request count
1064
+ */
1065
+ public startHourlyLogging(): void {
1066
+ // Log immediately on startup
1067
+ this.log.info?.(`[API Stats] Today (${this.date}): ${this.count} API requests`)
1068
+
1069
+ // Then log every hour
1070
+ this.hourlyTimer = setInterval(() => {
1071
+ const today = new Date().toISOString().split('T')[0]
1072
+ if (this.date !== today) {
1073
+ // Day rollover
1074
+ this.log.info?.(`[API Stats] Day rollover - Previous day (${this.date}): ${this.count} API requests`)
1075
+ this.count = 0
1076
+ this.date = today
1077
+ this.save()
1078
+ }
1079
+ this.log.info?.(`[API Stats] Today (${this.date}): ${this.count} API requests`)
1080
+ }, 60 * 60 * 1000) // Every hour
1081
+ }
1082
+
1083
+ /**
1084
+ * Stop hourly logging
1085
+ */
1086
+ public stopHourlyLogging(): void {
1087
+ if (this.hourlyTimer) {
1088
+ clearInterval(this.hourlyTimer)
1089
+ this.hourlyTimer = undefined
1090
+ }
1091
+ }
1092
+
1093
+ /**
1094
+ * Get current count
1095
+ */
1096
+ public getCount(): number {
1097
+ return this.count
1098
+ }
1099
+
1100
+ /**
1101
+ * Get current date
1102
+ */
1103
+ public getDate(): string {
1104
+ return this.date
1105
+ }
1106
+ }