@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.
- package/config.schema.json +12 -0
- package/dist/platform-hap.d.ts +1 -0
- package/dist/platform-hap.d.ts.map +1 -1
- package/dist/platform-hap.js +14 -1
- package/dist/platform-hap.js.map +1 -1
- package/dist/platform-matter.d.ts +19 -0
- package/dist/platform-matter.d.ts.map +1 -1
- package/dist/platform-matter.js +255 -70
- package/dist/platform-matter.js.map +1 -1
- package/dist/settings.d.ts +2 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js.map +1 -1
- package/dist/utils.d.ts +40 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +126 -0
- package/dist/utils.js.map +1 -1
- package/docs/variables/default.html +1 -1
- package/package.json +1 -1
- package/src/platform-hap.ts +16 -1
- package/src/platform-matter.ts +262 -66
- package/src/settings.ts +3 -0
- package/src/utils.ts +137 -4
package/src/platform-matter.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
|
918
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
}
|
|
963
|
-
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
|
|
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
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
|
|
1362
|
-
const
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
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
|
+
}
|