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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/settings.ts CHANGED
@@ -59,6 +59,24 @@ export interface options {
59
59
  updateRate?: number
60
60
  pushRate?: number
61
61
  logging?: string
62
+ /**
63
+ * Maximum number of SwitchBot OpenAPI requests allowed per day.
64
+ * Defaults to 10,000 if not specified.
65
+ */
66
+ dailyApiLimit?: number
67
+ /**
68
+ * Number of daily API requests reserved for user-initiated commands.
69
+ * When remaining budget falls below this reserve, background polling and discovery
70
+ * are paused until the daily counter resets. Defaults to 1,000.
71
+ */
72
+ dailyApiReserveForCommands?: number
73
+ /**
74
+ * When true, the plugin will completely stop background polling/discovery
75
+ * once the remaining daily budget reaches the reserve (webhook-only mode).
76
+ * When false, polling continues until the hard daily limit is reached.
77
+ * Default: false.
78
+ */
79
+ webhookOnlyOnReserve?: boolean
62
80
  // Matter platform batch refresh options
63
81
  matterBatchRefreshRate?: number
64
82
  matterBatchConcurrency?: number
package/src/utils.ts CHANGED
@@ -963,11 +963,20 @@ export class ApiRequestTracker {
963
963
  private date = ''
964
964
  private statsFile = ''
965
965
  private hourlyTimer?: NodeJS.Timeout
966
+ private midnightTimer?: NodeJS.Timeout
966
967
  private log: Logging
968
+ // Daily limits
969
+ private dailyLimit: number
970
+ private reserveForCommands: number
971
+ private lastWarn: Record<string, number> = {}
972
+ private pausePollingAtReserve = false
967
973
 
968
- constructor(api: API, log: Logging, pluginName = 'SwitchBot') {
974
+ constructor(api: API, log: Logging, pluginName = 'SwitchBot', limits?: { dailyLimit?: number, reserveForCommands?: number, pausePollingAtReserve?: boolean }) {
969
975
  this.log = log
970
976
  this.statsFile = join(api.user.storagePath(), `${pluginName.toLowerCase()}-api-stats.json`)
977
+ this.dailyLimit = Math.max(0, Number(limits?.dailyLimit ?? 10000))
978
+ this.reserveForCommands = Math.max(0, Number(limits?.reserveForCommands ?? 1000))
979
+ this.pausePollingAtReserve = Boolean(limits?.pausePollingAtReserve ?? false)
971
980
  this.load()
972
981
  }
973
982
 
@@ -1038,6 +1047,50 @@ export class ApiRequestTracker {
1038
1047
  this.save()
1039
1048
  }
1040
1049
 
1050
+ /**
1051
+ * Attempt to spend from the daily budget for a request of a given kind.
1052
+ * Kinds: 'command' (user actions), 'poll' (status refresh), 'discovery'.
1053
+ * Returns true if allowed (and increments the counter), false if blocked.
1054
+ */
1055
+ public trySpend(kind: 'command' | 'poll' | 'discovery', n = 1): boolean {
1056
+ const today = new Date().toISOString().split('T')[0]
1057
+ if (this.date !== today) {
1058
+ // Day rollover
1059
+ this.log.debug?.(`[API Stats] Day rollover: ${this.count} requests on ${this.date}`)
1060
+ this.count = 0
1061
+ this.date = today
1062
+ this.save()
1063
+ }
1064
+
1065
+ const softCap = Math.max(0, this.dailyLimit - this.reserveForCommands)
1066
+ const projected = this.count + n
1067
+ const now = Date.now()
1068
+ const overHardCap = projected > this.dailyLimit
1069
+ const overSoftCap = projected > softCap
1070
+ const shouldRateLimit = (kind === 'command')
1071
+ ? overHardCap
1072
+ : (this.pausePollingAtReserve ? overSoftCap : overHardCap)
1073
+
1074
+ if (shouldRateLimit) {
1075
+ const warnKey = kind === 'command' ? 'hardcap' : 'softcap'
1076
+ const last = this.lastWarn[warnKey] ?? 0
1077
+ if (now - last > 10 * 60 * 1000) { // warn at most every 10 minutes
1078
+ if (kind === 'command') {
1079
+ this.log.error?.(`[API Stats] Daily limit (${this.dailyLimit}) reached. Blocking command requests until reset.`)
1080
+ } else {
1081
+ const remainingForCommands = Math.max(0, this.dailyLimit - this.count)
1082
+ this.log.warn?.(`[API Stats] Near daily limit. Pausing ${kind} requests to reserve ~${this.reserveForCommands} calls for commands. Remaining today: ${remainingForCommands}`)
1083
+ }
1084
+ this.lastWarn[warnKey] = now
1085
+ }
1086
+ return false
1087
+ }
1088
+
1089
+ this.count += n
1090
+ this.save()
1091
+ return true
1092
+ }
1093
+
1041
1094
  /**
1042
1095
  * Start hourly logging of API request count
1043
1096
  */
@@ -1054,9 +1107,13 @@ export class ApiRequestTracker {
1054
1107
  this.count = 0
1055
1108
  this.date = today
1056
1109
  this.save()
1110
+ this.log.info?.('[API Stats] Polling resumed after daily reset')
1057
1111
  }
1058
1112
  this.log.info?.(`[API Stats] Today (${this.date}): ${this.count} API requests`)
1059
1113
  }, 60 * 60 * 1000) // Every hour
1114
+
1115
+ // Schedule an exact UTC midnight rollover log/reset
1116
+ this.scheduleMidnightRollover()
1060
1117
  }
1061
1118
 
1062
1119
  /**
@@ -1067,6 +1124,10 @@ export class ApiRequestTracker {
1067
1124
  clearInterval(this.hourlyTimer)
1068
1125
  this.hourlyTimer = undefined
1069
1126
  }
1127
+ if (this.midnightTimer) {
1128
+ clearTimeout(this.midnightTimer)
1129
+ this.midnightTimer = undefined
1130
+ }
1070
1131
  }
1071
1132
 
1072
1133
  /**
@@ -1082,6 +1143,44 @@ export class ApiRequestTracker {
1082
1143
  public getDate(): string {
1083
1144
  return this.date
1084
1145
  }
1146
+
1147
+ /** Schedule a precise log/reset at the next UTC midnight */
1148
+ private scheduleMidnightRollover(): void {
1149
+ try {
1150
+ // Clear any previous timer
1151
+ if (this.midnightTimer) {
1152
+ clearTimeout(this.midnightTimer)
1153
+ this.midnightTimer = undefined
1154
+ }
1155
+ 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())
1166
+ this.midnightTimer = setTimeout(() => {
1167
+ try {
1168
+ const today = new Date().toISOString().split('T')[0]
1169
+ if (this.date !== today) {
1170
+ this.log.info?.(`[API Stats] Day rollover - Previous day (${this.date}): ${this.count} API requests`)
1171
+ this.count = 0
1172
+ this.date = today
1173
+ this.save()
1174
+ }
1175
+ // 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`)
1178
+ } catch {}
1179
+ // Reschedule for the next midnight
1180
+ this.scheduleMidnightRollover()
1181
+ }, delay)
1182
+ } catch {}
1183
+ }
1085
1184
  }
1086
1185
 
1087
1186
  /**