homebridge-tsvesync
Version:
Homebridge plugin for VeSync devices including Levoit air purifiers, humidifiers, and Etekcity smart outlets
228 lines • 11.3 kB
JavaScript
;
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