@switchbot/homebridge-switchbot 5.0.0-beta.42 → 5.0.0-beta.43
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/CHANGELOG.md +2 -0
- package/README.md +22 -0
- package/config.schema.json +10 -0
- package/dist/platform-hap.d.ts +5 -0
- package/dist/platform-hap.d.ts.map +1 -1
- package/dist/platform-hap.js +68 -14
- package/dist/platform-hap.js.map +1 -1
- package/dist/platform-matter.d.ts +5 -0
- package/dist/platform-matter.d.ts.map +1 -1
- package/dist/platform-matter.js +60 -6
- package/dist/platform-matter.js.map +1 -1
- package/dist/settings.d.ts +5 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js.map +1 -1
- package/dist/utils.d.ts +8 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +64 -16
- package/dist/utils.js.map +1 -1
- package/docs/assets/highlight.css +14 -0
- package/docs/index.html +12 -1
- package/docs/variables/default.html +1 -1
- package/package.json +1 -1
- package/src/platform-hap.ts +72 -14
- package/src/platform-matter.ts +67 -6
- package/src/settings.ts +5 -0
- package/src/utils.ts +74 -25
|
@@ -1 +1 @@
|
|
|
1
|
-
<!DOCTYPE html><html class="default" lang="en" data-base="../"><head><meta charset="utf-8"/><meta http-equiv="x-ua-compatible" content="IE=edge"/><title>default | @switchbot/homebridge-switchbot</title><meta name="description" content="Documentation for @switchbot/homebridge-switchbot"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="../assets/style.css"/><link rel="stylesheet" href="../assets/highlight.css"/><script defer src="../assets/main.js"></script><script async src="../assets/icons.js" id="tsd-icons-script"></script><script async src="../assets/search.js" id="tsd-search-script"></script><script async src="../assets/navigation.js" id="tsd-nav-script"></script></head><body><script>document.documentElement.dataset.theme = localStorage.getItem("tsd-theme") || "os";document.body.style.display="none";setTimeout(() => window.app?app.showPage():document.body.style.removeProperty("display"),500)</script><header class="tsd-page-toolbar"><div class="tsd-toolbar-contents container"><a href="../index.html" class="title">@switchbot/homebridge-switchbot</a><div id="tsd-toolbar-links"></div><button id="tsd-search-trigger" class="tsd-widget" aria-label="Search"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><use href="../assets/icons.svg#icon-search"></use></svg></button><dialog id="tsd-search" aria-label="Search"><input role="combobox" id="tsd-search-input" aria-controls="tsd-search-results" aria-autocomplete="list" aria-expanded="true" autocapitalize="off" autocomplete="off" placeholder="Search the docs" maxLength="100"/><ul role="listbox" id="tsd-search-results"></ul><div id="tsd-search-status" aria-live="polite" aria-atomic="true"><div>Preparing search index...</div></div></dialog><a href="#" class="tsd-widget menu" id="tsd-toolbar-menu-trigger" data-toggle="menu" aria-label="Menu"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><use href="../assets/icons.svg#icon-menu"></use></svg></a></div></header><div class="container container-main"><div class="col-content"><div class="tsd-page-title"><ul class="tsd-breadcrumb" aria-label="Breadcrumb"><li><a href="" aria-current="page">default</a></li></ul><h1>Variable default</h1></div><div class="tsd-signature"><span class="tsd-kind-variable">default</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-symbol">(</span><span class="tsd-kind-parameter">api</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-type">API</span><span class="tsd-signature-symbol">)</span> <span class="tsd-signature-symbol">=></span> <span class="tsd-signature-type">void</span></div><div class="tsd-type-declaration"><h4>Type Declaration</h4><ul class="tsd-parameters"><li class="tsd-parameter-signature"><ul class="tsd-signatures"><li class="tsd-signature" id="__type"><span class="tsd-signature-symbol">(</span><span class="tsd-kind-parameter">api</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-type">API</span><span class="tsd-signature-symbol">)</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-type">void</span></li><li class="tsd-description"><div class="tsd-parameters"><h4 class="tsd-parameters-title">Parameters</h4><ul class="tsd-parameter-list"><li><span><span class="tsd-kind-parameter">api</span>: <span class="tsd-signature-type">API</span></span></li></ul></div><h4 class="tsd-returns-title">Returns <span class="tsd-signature-type">void</span></h4></li></ul></li></ul></div><aside class="tsd-sources"><ul><li>Defined in <a href="https://github.com/OpenWonderLabs/homebridge-switchbot/blob/
|
|
1
|
+
<!DOCTYPE html><html class="default" lang="en" data-base="../"><head><meta charset="utf-8"/><meta http-equiv="x-ua-compatible" content="IE=edge"/><title>default | @switchbot/homebridge-switchbot</title><meta name="description" content="Documentation for @switchbot/homebridge-switchbot"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="../assets/style.css"/><link rel="stylesheet" href="../assets/highlight.css"/><script defer src="../assets/main.js"></script><script async src="../assets/icons.js" id="tsd-icons-script"></script><script async src="../assets/search.js" id="tsd-search-script"></script><script async src="../assets/navigation.js" id="tsd-nav-script"></script></head><body><script>document.documentElement.dataset.theme = localStorage.getItem("tsd-theme") || "os";document.body.style.display="none";setTimeout(() => window.app?app.showPage():document.body.style.removeProperty("display"),500)</script><header class="tsd-page-toolbar"><div class="tsd-toolbar-contents container"><a href="../index.html" class="title">@switchbot/homebridge-switchbot</a><div id="tsd-toolbar-links"></div><button id="tsd-search-trigger" class="tsd-widget" aria-label="Search"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><use href="../assets/icons.svg#icon-search"></use></svg></button><dialog id="tsd-search" aria-label="Search"><input role="combobox" id="tsd-search-input" aria-controls="tsd-search-results" aria-autocomplete="list" aria-expanded="true" autocapitalize="off" autocomplete="off" placeholder="Search the docs" maxLength="100"/><ul role="listbox" id="tsd-search-results"></ul><div id="tsd-search-status" aria-live="polite" aria-atomic="true"><div>Preparing search index...</div></div></dialog><a href="#" class="tsd-widget menu" id="tsd-toolbar-menu-trigger" data-toggle="menu" aria-label="Menu"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><use href="../assets/icons.svg#icon-menu"></use></svg></a></div></header><div class="container container-main"><div class="col-content"><div class="tsd-page-title"><ul class="tsd-breadcrumb" aria-label="Breadcrumb"><li><a href="" aria-current="page">default</a></li></ul><h1>Variable default</h1></div><div class="tsd-signature"><span class="tsd-kind-variable">default</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-symbol">(</span><span class="tsd-kind-parameter">api</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-type">API</span><span class="tsd-signature-symbol">)</span> <span class="tsd-signature-symbol">=></span> <span class="tsd-signature-type">void</span></div><div class="tsd-type-declaration"><h4>Type Declaration</h4><ul class="tsd-parameters"><li class="tsd-parameter-signature"><ul class="tsd-signatures"><li class="tsd-signature" id="__type"><span class="tsd-signature-symbol">(</span><span class="tsd-kind-parameter">api</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-type">API</span><span class="tsd-signature-symbol">)</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-type">void</span></li><li class="tsd-description"><div class="tsd-parameters"><h4 class="tsd-parameters-title">Parameters</h4><ul class="tsd-parameter-list"><li><span><span class="tsd-kind-parameter">api</span>: <span class="tsd-signature-type">API</span></span></li></ul></div><h4 class="tsd-returns-title">Returns <span class="tsd-signature-type">void</span></h4></li></ul></li></ul></div><aside class="tsd-sources"><ul><li>Defined in <a href="https://github.com/OpenWonderLabs/homebridge-switchbot/blob/187ec8b0ea17bf889fba183a01838cf75dbd7e32/src/index.ts#L13">index.ts:13</a></li></ul></aside></div><div class="col-sidebar"><div class="page-menu"><div class="tsd-navigation settings"><details class="tsd-accordion"><summary class="tsd-accordion-summary"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true"><use href="../assets/icons.svg#icon-chevronDown"></use></svg><h3>Settings</h3></summary><div class="tsd-accordion-details"><div class="tsd-filter-visibility"><span class="settings-label">Member Visibility</span><ul id="tsd-filter-options"><li class="tsd-filter-item"><label class="tsd-filter-input"><input type="checkbox" id="tsd-filter-inherited" name="inherited" checked/><svg width="32" height="32" viewBox="0 0 32 32" aria-hidden="true"><rect class="tsd-checkbox-background" width="30" height="30" x="1" y="1" rx="6" fill="none"></rect><path class="tsd-checkbox-checkmark" d="M8.35422 16.8214L13.2143 21.75L24.6458 10.25" stroke="none" stroke-width="3.5" stroke-linejoin="round" fill="none"></path></svg><span>Inherited</span></label></li></ul></div><div class="tsd-theme-toggle"><label class="settings-label" for="tsd-theme">Theme</label><select id="tsd-theme"><option value="os">OS</option><option value="light">Light</option><option value="dark">Dark</option></select></div></div></details></div></div><div class="site-menu"><nav class="tsd-navigation"><a href="../modules.html">@switchbot/homebridge-switchbot</a><ul class="tsd-small-nested-navigation" id="tsd-nav-container"><li>Loading...</li></ul></nav></div></div></div><footer></footer><div class="overlay"></div></body></html>
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@switchbot/homebridge-switchbot",
|
|
3
3
|
"displayName": "SwitchBot",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "5.0.0-beta.
|
|
5
|
+
"version": "5.0.0-beta.43",
|
|
6
6
|
"description": "The SwitchBot plugin allows you to access your SwitchBot device(s) from HomeKit.",
|
|
7
7
|
"author": "SwitchBot <support@wondertechlabs.com> (https://github.com/SwitchBot)",
|
|
8
8
|
"contributors": [
|
package/src/platform-hap.ts
CHANGED
|
@@ -243,6 +243,7 @@ export class SwitchBotHAPPlatform implements DynamicPlatformPlugin {
|
|
|
243
243
|
dailyLimit: dailyApiLimit,
|
|
244
244
|
reserveForCommands: dailyApiReserveForCommands,
|
|
245
245
|
pausePollingAtReserve: webhookOnlyOnReserve,
|
|
246
|
+
resetAtLocalMidnight: this.config.options?.dailyApiResetAtLocalMidnight ?? false,
|
|
246
247
|
})
|
|
247
248
|
this.apiTracker.startHourlyLogging()
|
|
248
249
|
} catch (e: any) {
|
|
@@ -506,8 +507,12 @@ export class SwitchBotHAPPlatform implements DynamicPlatformPlugin {
|
|
|
506
507
|
const { response, statusCode } = await this.switchBotAPI.getDevices()
|
|
507
508
|
this.debugLog(`response: ${JSON.stringify(response)}`)
|
|
508
509
|
if (this.isSuccessfulResponse(statusCode)) {
|
|
509
|
-
|
|
510
|
-
|
|
510
|
+
const deviceList = Array.isArray(response.body.deviceList) ? response.body.deviceList : []
|
|
511
|
+
const irDeviceList = Array.isArray(response.body.infraredRemoteList) ? response.body.infraredRemoteList : []
|
|
512
|
+
await this.handleDevices(deviceList)
|
|
513
|
+
await this.handleIRDevices(irDeviceList)
|
|
514
|
+
// Diagnostic: warn users if their device count + refresh rate may exceed daily limits
|
|
515
|
+
this.validateApiUsageConfig(deviceList.length, irDeviceList.length)
|
|
511
516
|
break
|
|
512
517
|
} else {
|
|
513
518
|
// Check if rate limit exceeded (429)
|
|
@@ -2912,23 +2917,26 @@ export class SwitchBotHAPPlatform implements DynamicPlatformPlugin {
|
|
|
2912
2917
|
}
|
|
2913
2918
|
|
|
2914
2919
|
async retryRequest(device: (device & devicesConfig) | (irdevice & irDevicesConfig), deviceMaxRetries: number, deviceDelayBetweenRetries: number): Promise<{ response: any, statusCode: deviceStatusRequest['statusCode'] }> {
|
|
2920
|
+
// Check API budget BEFORE attempting any retries - don't waste cycles on blocked requests
|
|
2921
|
+
if (!this.apiTracker?.trySpend('poll')) {
|
|
2922
|
+
// Don't log on every blocked request - the ApiRequestTracker handles periodic warnings
|
|
2923
|
+
return {
|
|
2924
|
+
response: {
|
|
2925
|
+
deviceId: '',
|
|
2926
|
+
deviceType: '',
|
|
2927
|
+
hubDeviceId: '',
|
|
2928
|
+
version: 0,
|
|
2929
|
+
deviceName: '',
|
|
2930
|
+
},
|
|
2931
|
+
statusCode: 429,
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2915
2935
|
let retryCount = 0
|
|
2916
2936
|
const maxRetries = deviceMaxRetries
|
|
2917
2937
|
const delayBetweenRetries = deviceDelayBetweenRetries
|
|
2918
2938
|
while (retryCount < maxRetries) {
|
|
2919
2939
|
try {
|
|
2920
|
-
if (!this.apiTracker?.trySpend('poll')) {
|
|
2921
|
-
return {
|
|
2922
|
-
response: {
|
|
2923
|
-
deviceId: '',
|
|
2924
|
-
deviceType: '',
|
|
2925
|
-
hubDeviceId: '',
|
|
2926
|
-
version: 0,
|
|
2927
|
-
deviceName: '',
|
|
2928
|
-
},
|
|
2929
|
-
statusCode: 429,
|
|
2930
|
-
}
|
|
2931
|
-
}
|
|
2932
2940
|
const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(device.deviceId, this.config.credentials?.token, this.config.credentials?.secret)
|
|
2933
2941
|
this.debugLog(`response: ${JSON.stringify(response)}`)
|
|
2934
2942
|
return { response, statusCode }
|
|
@@ -3055,6 +3063,56 @@ export class SwitchBotHAPPlatform implements DynamicPlatformPlugin {
|
|
|
3055
3063
|
this.version = version
|
|
3056
3064
|
}
|
|
3057
3065
|
|
|
3066
|
+
/**
|
|
3067
|
+
* Validate that the user's configuration won't exceed API limits
|
|
3068
|
+
* Warn if device count × polling frequency will hit daily limits
|
|
3069
|
+
*/
|
|
3070
|
+
private validateApiUsageConfig(deviceCount: number, irDeviceCount: number): void {
|
|
3071
|
+
try {
|
|
3072
|
+
const totalDevices = deviceCount + irDeviceCount
|
|
3073
|
+
if (totalDevices === 0) {
|
|
3074
|
+
return
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
const refreshRate = this.platformRefreshRate ?? 300 // seconds
|
|
3078
|
+
const dailyLimit = this.config.options?.dailyApiLimit ?? 10000
|
|
3079
|
+
const reserveForCommands = this.config.options?.dailyApiReserveForCommands ?? 1000
|
|
3080
|
+
|
|
3081
|
+
// Calculate polls per day (86400 seconds in a day)
|
|
3082
|
+
const pollsPerDevicePerDay = Math.floor(86400 / refreshRate)
|
|
3083
|
+
const totalPollsPerDay = pollsPerDevicePerDay * totalDevices
|
|
3084
|
+
|
|
3085
|
+
// Add discovery calls (typically 1-2 per day)
|
|
3086
|
+
const estimatedDiscoveryCalls = 2
|
|
3087
|
+
const totalEstimatedCalls = totalPollsPerDay + estimatedDiscoveryCalls
|
|
3088
|
+
|
|
3089
|
+
const usableLimit = dailyLimit - reserveForCommands
|
|
3090
|
+
const percentOfLimit = Math.round((totalEstimatedCalls / usableLimit) * 100)
|
|
3091
|
+
|
|
3092
|
+
this.debugLog(`[API Usage Diagnostic] ${totalDevices} devices × ${pollsPerDevicePerDay} polls/day = ${totalPollsPerDay} estimated daily polls`)
|
|
3093
|
+
this.debugLog(`[API Usage Diagnostic] With ${reserveForCommands} reserved for commands, usable limit is ${usableLimit}`)
|
|
3094
|
+
|
|
3095
|
+
if (totalEstimatedCalls > dailyLimit) {
|
|
3096
|
+
this.errorLog(`⚠️ API LIMIT WARNING: Your configuration will exceed the daily API limit!`)
|
|
3097
|
+
this.errorLog(` Devices: ${totalDevices} | Refresh rate: ${refreshRate}s | Estimated daily polls: ${totalEstimatedCalls}`)
|
|
3098
|
+
this.errorLog(` Daily limit: ${dailyLimit} | You will use ${percentOfLimit}% of available budget`)
|
|
3099
|
+
this.errorLog(` SOLUTION: Increase refreshRate to ${Math.ceil((totalDevices * 86400) / usableLimit)} seconds or higher`)
|
|
3100
|
+
this.errorLog(` OR: Enable webhooks and set 'webhookOnlyOnReserve: true' to reduce polling`)
|
|
3101
|
+
} else if (totalEstimatedCalls > usableLimit) {
|
|
3102
|
+
this.warnLog(`⚠️ API USAGE WARNING: Configuration may exceed usable daily API budget`)
|
|
3103
|
+
this.warnLog(` Devices: ${totalDevices} | Refresh rate: ${refreshRate}s | Estimated daily polls: ${totalEstimatedCalls}`)
|
|
3104
|
+
this.warnLog(` Usable limit (after reserve): ${usableLimit} | You will use ${percentOfLimit}% of budget`)
|
|
3105
|
+
this.warnLog(` Polling may pause when approaching limit. Consider increasing refreshRate to ${Math.ceil((totalDevices * 86400) / usableLimit)}s`)
|
|
3106
|
+
} else if (percentOfLimit > 75) {
|
|
3107
|
+
this.infoLog(`[API Usage] Using ${percentOfLimit}% of daily budget (${totalEstimatedCalls}/${usableLimit} calls). Monitor usage if adding more devices.`)
|
|
3108
|
+
} else {
|
|
3109
|
+
this.debugLog(`[API Usage] Configuration looks good: ${percentOfLimit}% of daily budget (${totalEstimatedCalls}/${usableLimit} calls)`)
|
|
3110
|
+
}
|
|
3111
|
+
} catch (e: any) {
|
|
3112
|
+
this.debugErrorLog(`Failed to validate API usage config: ${e.message ?? e}`)
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3058
3116
|
/**
|
|
3059
3117
|
* Validate and clean a string value for a Name Characteristic.
|
|
3060
3118
|
* @param displayName - The display name of the accessory.
|
package/src/platform-matter.ts
CHANGED
|
@@ -185,6 +185,7 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
185
185
|
dailyLimit: this.config.options?.dailyApiLimit ?? 10000,
|
|
186
186
|
reserveForCommands: this.config.options?.dailyApiReserveForCommands ?? 1000,
|
|
187
187
|
pausePollingAtReserve: this.config.options?.webhookOnlyOnReserve ?? false,
|
|
188
|
+
resetAtLocalMidnight: this.config.options?.dailyApiResetAtLocalMidnight ?? false,
|
|
188
189
|
})
|
|
189
190
|
this.apiTracker.startHourlyLogging()
|
|
190
191
|
} catch (e: any) {
|
|
@@ -1137,6 +1138,9 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
1137
1138
|
for (const d of irDeviceList) {
|
|
1138
1139
|
this.debugLog(` - ${d.deviceName} (${d.remoteType}) id=${d.deviceId}`)
|
|
1139
1140
|
}
|
|
1141
|
+
|
|
1142
|
+
// Diagnostic: warn users if their device count + refresh rate may exceed daily limits
|
|
1143
|
+
this.validateApiUsageConfig(deviceList.length, irDeviceList.length)
|
|
1140
1144
|
} else {
|
|
1141
1145
|
this.warnLog(`SwitchBot getDevices returned status ${statusCode}`)
|
|
1142
1146
|
// If rate limit exceeded (429), log specific message
|
|
@@ -1227,15 +1231,17 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
1227
1231
|
* Retry wrapper for control commands using SwitchBot OpenAPI
|
|
1228
1232
|
*/
|
|
1229
1233
|
async retryCommand(deviceObj: device, bodyChange: bodyChange, maxRetries = 1, delayBetweenRetries = 1000): Promise<{ response: any, statusCode: number }> {
|
|
1234
|
+
// Check API budget BEFORE attempting any retries
|
|
1235
|
+
if (!this.apiTracker?.trySpend('command')) {
|
|
1236
|
+
return { response: {}, statusCode: 429 }
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1230
1239
|
let retryCount = 0
|
|
1231
1240
|
while (retryCount < maxRetries) {
|
|
1232
1241
|
try {
|
|
1233
1242
|
if (!this.switchBotAPI) {
|
|
1234
1243
|
throw new Error('SwitchBot OpenAPI not initialized')
|
|
1235
1244
|
}
|
|
1236
|
-
if (!this.apiTracker?.trySpend('command')) {
|
|
1237
|
-
return { response: {}, statusCode: 429 }
|
|
1238
|
-
}
|
|
1239
1245
|
const { response, statusCode } = await this.switchBotAPI.controlDevice(
|
|
1240
1246
|
deviceObj.deviceId,
|
|
1241
1247
|
bodyChange.command,
|
|
@@ -2399,12 +2405,16 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
2399
2405
|
/** Refresh a single device with retry and backoff; returns status object if successful */
|
|
2400
2406
|
private async refreshSingleDeviceWithRetry(dev: device, retries = 3, baseDelayMs = 500): Promise<any | null> {
|
|
2401
2407
|
const deviceId = dev.deviceId
|
|
2408
|
+
|
|
2409
|
+
// Check API budget BEFORE attempting any retries - don't waste cycles on blocked requests
|
|
2410
|
+
if (!this.apiTracker?.trySpend('poll')) {
|
|
2411
|
+
// Don't log on every blocked request - the ApiRequestTracker handles periodic warnings
|
|
2412
|
+
return null
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2402
2415
|
let attempt = 0
|
|
2403
2416
|
while (attempt <= retries) {
|
|
2404
2417
|
try {
|
|
2405
|
-
if (!this.apiTracker?.trySpend('poll')) {
|
|
2406
|
-
return null
|
|
2407
|
-
}
|
|
2408
2418
|
const { response, statusCode } = await this.switchBotAPI!.getDeviceStatus(deviceId, this.config.credentials?.token, this.config.credentials?.secret)
|
|
2409
2419
|
const respAny: any = response
|
|
2410
2420
|
const body = respAny?.body ?? respAny
|
|
@@ -2435,6 +2445,57 @@ export class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
2435
2445
|
return null
|
|
2436
2446
|
}
|
|
2437
2447
|
|
|
2448
|
+
/**
|
|
2449
|
+
* Validate that the user's configuration won't exceed API limits
|
|
2450
|
+
* Warn if device count × polling frequency will hit daily limits
|
|
2451
|
+
*/
|
|
2452
|
+
private validateApiUsageConfig(deviceCount: number, irDeviceCount: number): void {
|
|
2453
|
+
try {
|
|
2454
|
+
const totalDevices = deviceCount + irDeviceCount
|
|
2455
|
+
if (totalDevices === 0) {
|
|
2456
|
+
return
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
// For Matter platform, use matterBatchRefreshRate or fallback to refreshRate
|
|
2460
|
+
const refreshRate = this.getPlatformBatchInterval() // seconds
|
|
2461
|
+
const dailyLimit = this.config.options?.dailyApiLimit ?? 10000
|
|
2462
|
+
const reserveForCommands = this.config.options?.dailyApiReserveForCommands ?? 1000
|
|
2463
|
+
|
|
2464
|
+
// Calculate polls per day (86400 seconds in a day)
|
|
2465
|
+
const pollsPerDevicePerDay = Math.floor(86400 / refreshRate)
|
|
2466
|
+
const totalPollsPerDay = pollsPerDevicePerDay * totalDevices
|
|
2467
|
+
|
|
2468
|
+
// Add discovery calls (typically 1-2 per day)
|
|
2469
|
+
const estimatedDiscoveryCalls = 2
|
|
2470
|
+
const totalEstimatedCalls = totalPollsPerDay + estimatedDiscoveryCalls
|
|
2471
|
+
|
|
2472
|
+
const usableLimit = dailyLimit - reserveForCommands
|
|
2473
|
+
const percentOfLimit = Math.round((totalEstimatedCalls / usableLimit) * 100)
|
|
2474
|
+
|
|
2475
|
+
this.debugLog(`[API Usage Diagnostic] ${totalDevices} devices × ${pollsPerDevicePerDay} polls/day = ${totalPollsPerDay} estimated daily polls`)
|
|
2476
|
+
this.debugLog(`[API Usage Diagnostic] With ${reserveForCommands} reserved for commands, usable limit is ${usableLimit}`)
|
|
2477
|
+
|
|
2478
|
+
if (totalEstimatedCalls > dailyLimit) {
|
|
2479
|
+
this.errorLog(`⚠️ API LIMIT WARNING: Your configuration will exceed the daily API limit!`)
|
|
2480
|
+
this.errorLog(` Devices: ${totalDevices} | Refresh rate: ${refreshRate}s | Estimated daily polls: ${totalEstimatedCalls}`)
|
|
2481
|
+
this.errorLog(` Daily limit: ${dailyLimit} | You will use ${percentOfLimit}% of available budget`)
|
|
2482
|
+
this.errorLog(` SOLUTION: Increase matterBatchRefreshRate to ${Math.ceil((totalDevices * 86400) / usableLimit)} seconds or higher`)
|
|
2483
|
+
this.errorLog(` OR: Enable webhooks and set 'webhookOnlyOnReserve: true' to reduce polling`)
|
|
2484
|
+
} else if (totalEstimatedCalls > usableLimit) {
|
|
2485
|
+
this.warnLog(`⚠️ API USAGE WARNING: Configuration may exceed usable daily API budget`)
|
|
2486
|
+
this.warnLog(` Devices: ${totalDevices} | Refresh rate: ${refreshRate}s | Estimated daily polls: ${totalEstimatedCalls}`)
|
|
2487
|
+
this.warnLog(` Usable limit (after reserve): ${usableLimit} | You will use ${percentOfLimit}% of budget`)
|
|
2488
|
+
this.warnLog(` Polling may pause when approaching limit. Consider increasing matterBatchRefreshRate to ${Math.ceil((totalDevices * 86400) / usableLimit)}s`)
|
|
2489
|
+
} else if (percentOfLimit > 75) {
|
|
2490
|
+
this.infoLog(`[API Usage] Using ${percentOfLimit}% of daily budget (${totalEstimatedCalls}/${usableLimit} calls). Monitor usage if adding more devices.`)
|
|
2491
|
+
} else {
|
|
2492
|
+
this.debugLog(`[API Usage] Configuration looks good: ${percentOfLimit}% of daily budget (${totalEstimatedCalls}/${usableLimit} calls)`)
|
|
2493
|
+
}
|
|
2494
|
+
} catch (e: any) {
|
|
2495
|
+
this.debugLog(`Failed to validate API usage config: ${e?.message ?? e}`)
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2438
2499
|
/** Simple concurrency limiter for an array of items */
|
|
2439
2500
|
private async runWithConcurrency<T>(items: T[], worker: (item: T) => Promise<void>, concurrency: number): Promise<void> {
|
|
2440
2501
|
const queue = items.slice()
|
package/src/settings.ts
CHANGED
|
@@ -77,6 +77,11 @@ export interface options {
|
|
|
77
77
|
* Default: false.
|
|
78
78
|
*/
|
|
79
79
|
webhookOnlyOnReserve?: boolean
|
|
80
|
+
/**
|
|
81
|
+
* When true, reset the daily API request counter at LOCAL midnight (system timezone).
|
|
82
|
+
* When false (default), reset at UTC midnight. Default: false.
|
|
83
|
+
*/
|
|
84
|
+
dailyApiResetAtLocalMidnight?: boolean
|
|
80
85
|
// Matter platform batch refresh options
|
|
81
86
|
matterBatchRefreshRate?: number
|
|
82
87
|
matterBatchConcurrency?: number
|
package/src/utils.ts
CHANGED
|
@@ -970,22 +970,39 @@ export class ApiRequestTracker {
|
|
|
970
970
|
private reserveForCommands: number
|
|
971
971
|
private lastWarn: Record<string, number> = {}
|
|
972
972
|
private pausePollingAtReserve = false
|
|
973
|
+
private resetAtLocalMidnight = false
|
|
973
974
|
|
|
974
|
-
constructor(api: API, log: Logging, pluginName = 'SwitchBot', limits?: { dailyLimit?: number, reserveForCommands?: number, pausePollingAtReserve?: boolean }) {
|
|
975
|
+
constructor(api: API, log: Logging, pluginName = 'SwitchBot', limits?: { dailyLimit?: number, reserveForCommands?: number, pausePollingAtReserve?: boolean, resetAtLocalMidnight?: boolean }) {
|
|
975
976
|
this.log = log
|
|
976
977
|
this.statsFile = join(api.user.storagePath(), `${pluginName.toLowerCase()}-api-stats.json`)
|
|
977
978
|
this.dailyLimit = Math.max(0, Number(limits?.dailyLimit ?? 10000))
|
|
978
979
|
this.reserveForCommands = Math.max(0, Number(limits?.reserveForCommands ?? 1000))
|
|
979
980
|
this.pausePollingAtReserve = Boolean(limits?.pausePollingAtReserve ?? false)
|
|
981
|
+
this.resetAtLocalMidnight = Boolean(limits?.resetAtLocalMidnight ?? false)
|
|
980
982
|
this.load()
|
|
981
983
|
}
|
|
982
984
|
|
|
985
|
+
/**
|
|
986
|
+
* Return date key string (YYYY-MM-DD) based on reset mode
|
|
987
|
+
* - UTC (default): uses UTC date
|
|
988
|
+
* - Local: uses local timezone date
|
|
989
|
+
*/
|
|
990
|
+
private dateKey(now: Date = new Date()): string {
|
|
991
|
+
if (!this.resetAtLocalMidnight) {
|
|
992
|
+
return now.toISOString().split('T')[0]
|
|
993
|
+
}
|
|
994
|
+
const y = now.getFullYear()
|
|
995
|
+
const m = (now.getMonth() + 1).toString().padStart(2, '0')
|
|
996
|
+
const d = now.getDate().toString().padStart(2, '0')
|
|
997
|
+
return `${y}-${m}-${d}`
|
|
998
|
+
}
|
|
999
|
+
|
|
983
1000
|
/**
|
|
984
1001
|
* Load API request statistics from persistent storage
|
|
985
1002
|
*/
|
|
986
1003
|
private load(): void {
|
|
987
1004
|
try {
|
|
988
|
-
const today =
|
|
1005
|
+
const today = this.dateKey()
|
|
989
1006
|
|
|
990
1007
|
if (existsSync(this.statsFile)) {
|
|
991
1008
|
const data = JSON.parse(readFileSync(this.statsFile, 'utf8'))
|
|
@@ -996,7 +1013,7 @@ export class ApiRequestTracker {
|
|
|
996
1013
|
this.date = data.date
|
|
997
1014
|
this.log.warn?.(`[API Stats] Loaded: ${this.count} requests today (${today})`)
|
|
998
1015
|
} else {
|
|
999
|
-
this.log.error?.(`[API Stats] New day detected. Previous: ${data.count || 0} requests on ${data.date}`)
|
|
1016
|
+
this.log.error?.(`[API Stats] New day detected (${this.resetAtLocalMidnight ? 'local' : 'UTC'}). Previous: ${data.count || 0} requests on ${data.date}`)
|
|
1000
1017
|
this.count = 0
|
|
1001
1018
|
this.date = today
|
|
1002
1019
|
this.save()
|
|
@@ -1010,7 +1027,7 @@ export class ApiRequestTracker {
|
|
|
1010
1027
|
} catch (e: any) {
|
|
1011
1028
|
this.log.error?.(`[API Stats] Failed to load stats: ${e?.message ?? e}`)
|
|
1012
1029
|
this.count = 0
|
|
1013
|
-
this.date =
|
|
1030
|
+
this.date = this.dateKey()
|
|
1014
1031
|
}
|
|
1015
1032
|
}
|
|
1016
1033
|
|
|
@@ -1034,7 +1051,7 @@ export class ApiRequestTracker {
|
|
|
1034
1051
|
* Increment API request counter and save
|
|
1035
1052
|
*/
|
|
1036
1053
|
public track(): void {
|
|
1037
|
-
const today =
|
|
1054
|
+
const today = this.dateKey()
|
|
1038
1055
|
|
|
1039
1056
|
// Reset counter if it's a new day
|
|
1040
1057
|
if (this.date !== today) {
|
|
@@ -1053,7 +1070,7 @@ export class ApiRequestTracker {
|
|
|
1053
1070
|
* Returns true if allowed (and increments the counter), false if blocked.
|
|
1054
1071
|
*/
|
|
1055
1072
|
public trySpend(kind: 'command' | 'poll' | 'discovery', n = 1): boolean {
|
|
1056
|
-
const today =
|
|
1073
|
+
const today = this.dateKey()
|
|
1057
1074
|
if (this.date !== today) {
|
|
1058
1075
|
// Day rollover
|
|
1059
1076
|
this.log.debug?.(`[API Stats] Day rollover: ${this.count} requests on ${this.date}`)
|
|
@@ -1096,23 +1113,46 @@ export class ApiRequestTracker {
|
|
|
1096
1113
|
*/
|
|
1097
1114
|
public startHourlyLogging(): void {
|
|
1098
1115
|
// Log immediately on startup
|
|
1099
|
-
|
|
1116
|
+
const softCap = Math.max(0, this.dailyLimit - this.reserveForCommands)
|
|
1117
|
+
const remaining = Math.max(0, this.dailyLimit - this.count)
|
|
1118
|
+
const percentUsed = Math.round((this.count / this.dailyLimit) * 100)
|
|
1119
|
+
|
|
1120
|
+
this.log.info?.(`[API Stats] Today (${this.date}): ${this.count}/${this.dailyLimit} API requests (${percentUsed}%), ${remaining} remaining`)
|
|
1121
|
+
this.log.info?.(`[API Stats] Reset schedule: ${this.resetAtLocalMidnight ? 'local midnight' : 'UTC midnight'}`)
|
|
1122
|
+
|
|
1123
|
+
if (this.count >= this.dailyLimit) {
|
|
1124
|
+
this.log.warn?.('[API Stats] ⚠️ DAILY LIMIT REACHED - All API requests blocked until reset')
|
|
1125
|
+
} else if (this.count >= softCap) {
|
|
1126
|
+
this.log.warn?.(`[API Stats] ⚠️ NEAR LIMIT - Background polling paused, ${remaining} requests reserved for commands`)
|
|
1127
|
+
}
|
|
1100
1128
|
|
|
1101
1129
|
// Then log every hour
|
|
1102
1130
|
this.hourlyTimer = setInterval(() => {
|
|
1103
|
-
const today =
|
|
1131
|
+
const today = this.dateKey()
|
|
1104
1132
|
if (this.date !== today) {
|
|
1105
1133
|
// Day rollover
|
|
1106
1134
|
this.log.info?.(`[API Stats] Day rollover - Previous day (${this.date}): ${this.count} API requests`)
|
|
1107
1135
|
this.count = 0
|
|
1108
1136
|
this.date = today
|
|
1109
1137
|
this.save()
|
|
1110
|
-
this.log.info?.('[API Stats] Polling resumed after daily reset')
|
|
1138
|
+
this.log.info?.('[API Stats] ✅ Polling resumed after daily reset')
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const remaining = Math.max(0, this.dailyLimit - this.count)
|
|
1142
|
+
const percentUsed = Math.round((this.count / this.dailyLimit) * 100)
|
|
1143
|
+
const softCap = Math.max(0, this.dailyLimit - this.reserveForCommands)
|
|
1144
|
+
|
|
1145
|
+
// Provide context-aware status message
|
|
1146
|
+
if (this.count >= this.dailyLimit) {
|
|
1147
|
+
this.log.warn?.(`[API Stats] Today (${this.date}): ${this.count}/${this.dailyLimit} requests (${percentUsed}%) - ⚠️ LIMIT REACHED, all requests blocked`)
|
|
1148
|
+
} else if (this.count >= softCap) {
|
|
1149
|
+
this.log.warn?.(`[API Stats] Today (${this.date}): ${this.count}/${this.dailyLimit} requests (${percentUsed}%), ${remaining} remaining - polling paused`)
|
|
1150
|
+
} else {
|
|
1151
|
+
this.log.info?.(`[API Stats] Today (${this.date}): ${this.count}/${this.dailyLimit} requests (${percentUsed}%), ${remaining} remaining`)
|
|
1111
1152
|
}
|
|
1112
|
-
this.log.info?.(`[API Stats] Today (${this.date}): ${this.count} API requests`)
|
|
1113
1153
|
}, 60 * 60 * 1000) // Every hour
|
|
1114
1154
|
|
|
1115
|
-
// Schedule an exact
|
|
1155
|
+
// Schedule an exact midnight rollover log/reset
|
|
1116
1156
|
this.scheduleMidnightRollover()
|
|
1117
1157
|
}
|
|
1118
1158
|
|
|
@@ -1153,28 +1193,37 @@ export class ApiRequestTracker {
|
|
|
1153
1193
|
this.midnightTimer = undefined
|
|
1154
1194
|
}
|
|
1155
1195
|
const now = new Date()
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
now
|
|
1160
|
-
0,
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1196
|
+
let delay = 0
|
|
1197
|
+
if (this.resetAtLocalMidnight) {
|
|
1198
|
+
// Next local midnight
|
|
1199
|
+
const next = new Date(now)
|
|
1200
|
+
next.setHours(24, 0, 0, 0) // rolls to next day at 00:00 local
|
|
1201
|
+
delay = Math.max(1000, next.getTime() - now.getTime())
|
|
1202
|
+
} else {
|
|
1203
|
+
// Next UTC midnight
|
|
1204
|
+
const nextUtcMidnightMs = Date.UTC(
|
|
1205
|
+
now.getUTCFullYear(),
|
|
1206
|
+
now.getUTCMonth(),
|
|
1207
|
+
now.getUTCDate() + 1,
|
|
1208
|
+
0,
|
|
1209
|
+
0,
|
|
1210
|
+
0,
|
|
1211
|
+
0,
|
|
1212
|
+
)
|
|
1213
|
+
delay = Math.max(1000, nextUtcMidnightMs - now.getTime())
|
|
1214
|
+
}
|
|
1166
1215
|
this.midnightTimer = setTimeout(() => {
|
|
1167
1216
|
try {
|
|
1168
|
-
const today =
|
|
1217
|
+
const today = this.dateKey()
|
|
1169
1218
|
if (this.date !== today) {
|
|
1170
|
-
this.log.info?.(`[API Stats] Day rollover - Previous day (${this.date}): ${this.count} API requests`)
|
|
1219
|
+
this.log.info?.(`[API Stats] 🌙 Day rollover - Previous day (${this.date}): ${this.count} API requests`)
|
|
1171
1220
|
this.count = 0
|
|
1172
1221
|
this.date = today
|
|
1173
1222
|
this.save()
|
|
1174
1223
|
}
|
|
1175
1224
|
// Emit the precise resume line and a fresh today counter line
|
|
1176
|
-
this.log.info?.('[API Stats]
|
|
1177
|
-
this.log.info?.(`[API Stats] Today (${this.date}): ${this.count} API requests`)
|
|
1225
|
+
this.log.info?.('[API Stats] ✅ Daily API counter reset - Polling resumed')
|
|
1226
|
+
this.log.info?.(`[API Stats] Today (${this.date}): ${this.count}/${this.dailyLimit} API requests`)
|
|
1178
1227
|
} catch {}
|
|
1179
1228
|
// Reschedule for the next midnight
|
|
1180
1229
|
this.scheduleMidnightRollover()
|