@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.
@@ -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">=&gt;</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/f7f9cce24bb9dc876e5099caf107cffc48b7d5bb/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>
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">=&gt;</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.42",
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": [
@@ -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
- await this.handleDevices(Array.isArray(response.body.deviceList) ? response.body.deviceList : [])
510
- await this.handleIRDevices(Array.isArray(response.body.infraredRemoteList) ? response.body.infraredRemoteList : [])
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.
@@ -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 = new Date().toISOString().split('T')[0]
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 = new Date().toISOString().split('T')[0]
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 = new Date().toISOString().split('T')[0]
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 = new Date().toISOString().split('T')[0]
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
- this.log.info?.(`[API Stats] Today (${this.date}): ${this.count} API requests`)
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 = new Date().toISOString().split('T')[0]
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 UTC midnight rollover log/reset
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
- const nextUtcMidnightMs = Date.UTC(
1157
- now.getUTCFullYear(),
1158
- now.getUTCMonth(),
1159
- now.getUTCDate() + 1,
1160
- 0,
1161
- 0,
1162
- 0,
1163
- 0,
1164
- )
1165
- const delay = Math.max(1000, nextUtcMidnightMs - now.getTime())
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 = new Date().toISOString().split('T')[0]
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] Polling resumed after daily reset')
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()