UNPKG

homebridge-tsvesync

Version:

Homebridge plugin for VeSync devices including Levoit air purifiers, humidifiers, and Etekcity smart outlets

228 lines 11.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createRateLimitedVeSync = void 0; const tsvesync_1 = require("tsvesync"); const logger_1 = require("./logger"); const quota_manager_1 = require("./quota-manager"); const RATE_LIMIT_DELAY = 500; // 500ms between API calls const DEBOUNCE_DELAY = 5000; // 5 second debounce for rapid changes class RateLimiter { constructor(logger, deviceCount = 0, quotaConfig) { this.logger = logger; this.lastCallTime = 0; this.lastMethodName = ''; this.debounceTimers = new Map(); this.methodCallCounts = new Map(); this.lastMethodLogTime = 0; this.METHOD_LOG_INTERVAL = 10 * 60 * 1000; // 10 minutes in milliseconds this.quotaManager = new quota_manager_1.QuotaManager(logger, deviceCount, quotaConfig); } /** * Update the device count for quota calculation */ updateDeviceCount(count) { this.quotaManager.updateDeviceCount(count); } async rateLimit(methodName) { // First check if we can make this API call based on quota if (!this.quotaManager.canMakeApiCall(methodName)) { return false; } const now = Date.now(); const timeSinceLastCall = now - this.lastCallTime; if (timeSinceLastCall < RATE_LIMIT_DELAY) { const waitTime = RATE_LIMIT_DELAY - timeSinceLastCall; this.logger.debug(`Rate limiting '${methodName}' - waiting ${waitTime}ms before next API call (previous call was '${this.lastMethodName}' ${timeSinceLastCall}ms ago)`); await new Promise(resolve => setTimeout(resolve, waitTime)); } this.lastMethodName = methodName; this.lastCallTime = Date.now(); this.logger.debug(`Executing API call '${methodName}'`); // Track method call counts for diagnostics this.trackMethodCall(methodName); // Record this API call in the quota manager this.quotaManager.recordApiCall(methodName); return true; } /** * Track method call counts for diagnostics */ trackMethodCall(methodName) { // Increment the call count for this method const currentCount = this.methodCallCounts.get(methodName) || 0; this.methodCallCounts.set(methodName, currentCount + 1); // Log method call statistics periodically const now = Date.now(); if (now - this.lastMethodLogTime >= this.METHOD_LOG_INTERVAL) { this.logMethodCallStatistics(); this.lastMethodLogTime = now; } } /** * Log method call statistics to help identify frequent API calls */ logMethodCallStatistics() { // Sort methods by call count (descending) const sortedMethods = Array.from(this.methodCallCounts.entries()) .sort((a, b) => b[1] - a[1]); if (sortedMethods.length === 0) { return; } // Log the top 10 most frequently called methods this.logger.debug('Top API methods by call frequency:'); sortedMethods.slice(0, 10).forEach(([method, count], index) => { this.logger.debug(`${index + 1}. ${method}: ${count} calls`); }); // Calculate total calls const totalCalls = sortedMethods.reduce((sum, [_, count]) => sum + count, 0); this.logger.debug(`Total API calls: ${totalCalls}`); // Reset counters after logging this.methodCallCounts.clear(); } getDebounceKey(methodName, deviceId, args) { const paramsKey = args.length > 0 ? `-${JSON.stringify(args)}` : ''; return deviceId ? `${methodName}-${deviceId}${paramsKey}` : `${methodName}${paramsKey}`; } debounce(methodName, deviceId, fn, args) { // First check if we can make this API call based on quota if (!this.quotaManager.canMakeApiCall(methodName)) { // Log at WARN level as requested by user this.logger.warn(`Quota exceeded. Skipping API call: ${methodName}${deviceId ? ` for device ${deviceId}` : ''}${args.length > 0 ? ` with args ${JSON.stringify(args)}` : ''}`); return Promise.resolve(null); } const key = this.getDebounceKey(methodName, deviceId, args); const pending = this.debounceTimers.get(key); // If there's an active debounce timer and a call is in progress, skip this call if (pending === null || pending === void 0 ? void 0 : pending.inProgress) { this.logger.debug(`Skipping '${methodName}'${deviceId ? ` for device ${deviceId}` : ''} with args ${JSON.stringify(args)} - call already in progress`); return Promise.resolve(pending.lastValue); } // If we're within the debounce window but no call is in progress, return the last value if (pending && !pending.inProgress) { this.logger.debug(`Reusing last value for '${methodName}'${deviceId ? ` for device ${deviceId}` : ''} with args ${JSON.stringify(args)}`); return Promise.resolve(pending.lastValue); } // Execute the call return (async () => { try { // Mark call as in progress if (pending) { pending.inProgress = true; } this.logger.debug(`Executing '${methodName}'${deviceId ? ` for device ${deviceId}` : ''} with args ${JSON.stringify(args)}`); const canProceed = await this.rateLimit(methodName); if (!canProceed) { // Log at WARN level as requested by user this.logger.warn(`Quota check failed during execution. Skipping API call: ${methodName}${deviceId ? ` for device ${deviceId}` : ''}${args.length > 0 ? ` with args ${JSON.stringify(args)}` : ''}`); return null; } const result = await fn(); // Store the result and set the debounce timer const timer = setTimeout(() => { this.debounceTimers.delete(key); }, DEBOUNCE_DELAY); this.debounceTimers.set(key, { timer, lastValue: result, inProgress: false }); return result; } catch (error) { // If there was an error, clear the debounce timer if (pending) { clearTimeout(pending.timer); this.debounceTimers.delete(key); } this.logger.error(`Error in '${methodName}' call:`, error); throw error; } })(); } } // Create a proxy handler that adds rate limiting to API calls const createRateLimitedProxy = (target, rateLimiter, deviceId) => { return new Proxy(target, { get(target, prop) { const value = target[prop]; // If it's not a function or it's a getter, return as is if (typeof value !== 'function' || prop === 'get') { // If it's an array of devices, wrap each device in a proxy if (Array.isArray(value) && ['fans', 'outlets', 'switches', 'bulbs', 'humidifiers', 'purifiers'].includes(prop.toString())) { return value.map(device => createRateLimitedProxy(device, rateLimiter, device.cid)); } // If it's a device object (has deviceType and cid), wrap it in a proxy if (value && typeof value === 'object' && 'deviceType' in value && 'cid' in value) { return createRateLimitedProxy(value, rateLimiter, value.cid); } return value; } // Methods that bypass all rate limiting and debouncing const bypassMethods = [ // Getters and internal methods 'get', 'getService', 'getCharacteristic', 'hydrateSession', // State accessors 'deviceStatus', 'speed', 'brightness', 'colorTemp', 'mode', 'filterLife', 'airQuality', 'airQualityValue', 'screenStatus', 'childLock', 'pm1', 'pm10', 'humidity', 'mistLevel', // Event handlers 'onSet', 'onGet', 'addListener', 'removeListener', // Controlled interval methods 'update', // Feature detection and configuration methods (don't make API calls) 'hasFeature', 'getMaxFanSpeed', 'isFeatureSupportedInCurrentMode' ]; const methodName = prop.toString(); // For bypass methods, return a simple wrapper that calls the original synchronously if (bypassMethods.includes(methodName)) { return function (...args) { return value.apply(target, args); }; } // Return a proxied async function that applies rate limiting return async function (...args) { // Methods that should only be rate limited (no debouncing) const noDebounceAPIMethods = [ 'ignored' ]; if (noDebounceAPIMethods.includes(methodName)) { await rateLimiter.rateLimit(methodName); return value.apply(target, args); } // Everything else gets both rate limited and debounced return rateLimiter.debounce(methodName, deviceId, () => value.apply(target, args), args); }; } }); }; // Export a factory function that creates rate-limited VeSync instances const createRateLimitedVeSync = (username, password, timeZone, debug, redact, apiUrl, customLogger, exclusions, config, session) => { var _a; // Use the new options object pattern for VeSync constructor const client = new tsvesync_1.VeSync(username, password, timeZone, { debug: debug, redact: redact, apiUrl: apiUrl, customLogger: customLogger, excludeConfig: exclusions, countryCode: (config === null || config === void 0 ? void 0 : config.countryCode) || 'US', sessionStore: session === null || session === void 0 ? void 0 : session.store, onTokenChange: session === null || session === void 0 ? void 0 : session.onTokenChange }); const logger = customLogger || new logger_1.PluginLogger(console, debug || false); // Create rate limiter with quota management if enabled const quotaConfig = ((_a = config === null || config === void 0 ? void 0 : config.quotaManagement) === null || _a === void 0 ? void 0 : _a.enabled) ? { bufferPercentage: config.quotaManagement.bufferPercentage, priorityMethods: config.quotaManagement.priorityMethods } : undefined; const rateLimiter = new RateLimiter(logger, 0, quotaConfig); // Create the proxy const proxy = createRateLimitedProxy(client, rateLimiter); // Add a method to update device count for quota calculation proxy.updateQuotaDeviceCount = (count) => { rateLimiter.updateDeviceCount(count); }; return proxy; }; exports.createRateLimitedVeSync = createRateLimitedVeSync; //# sourceMappingURL=api-proxy.js.map