homebridge-tsvesync
Version:
Homebridge plugin for VeSync devices including Levoit air purifiers, humidifiers, and Etekcity smart outlets
285 lines (241 loc) • 10.1 kB
text/typescript
import { VeSync } from 'tsvesync';
import { PluginLogger } from './logger';
import { DeviceExclusion } from '../types/device.types';
import { QuotaManager } from './quota-manager';
const RATE_LIMIT_DELAY = 500; // 500ms between API calls
const DEBOUNCE_DELAY = 5000; // 5 second debounce for rapid changes
class RateLimiter {
private lastCallTime = 0;
private lastMethodName = '';
private debounceTimers: Map<string, {
timer: NodeJS.Timeout;
lastValue: any;
inProgress: boolean;
}> = new Map();
private quotaManager: QuotaManager;
private methodCallCounts: Map<string, number> = new Map();
private lastMethodLogTime = 0;
private readonly METHOD_LOG_INTERVAL = 10 * 60 * 1000; // 10 minutes in milliseconds
constructor(
private readonly logger: PluginLogger,
deviceCount: number = 0,
quotaConfig?: {
bufferPercentage?: number;
priorityMethods?: string[];
}
) {
this.quotaManager = new QuotaManager(logger, deviceCount, quotaConfig);
}
/**
* Update the device count for quota calculation
*/
updateDeviceCount(count: number): void {
this.quotaManager.updateDeviceCount(count);
}
async rateLimit(methodName: string): Promise<boolean> {
// 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
*/
private trackMethodCall(methodName: string): void {
// 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
*/
private logMethodCallStatistics(): void {
// 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();
}
private getDebounceKey(methodName: string, deviceId: string | undefined, args: any[]): string {
const paramsKey = args.length > 0 ? `-${JSON.stringify(args)}` : '';
return deviceId ? `${methodName}-${deviceId}${paramsKey}` : `${methodName}${paramsKey}`;
}
debounce<T>(methodName: string, deviceId: string | undefined, fn: () => Promise<T>, args: any[]): Promise<T | null> {
// 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?.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: any, rateLimiter: RateLimiter, deviceId?: string): any => {
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;
}
// Return a proxied function that applies rate limiting
return async function(...args: any[]) {
const methodName = prop.toString();
// Methods that bypass all rate limiting and debouncing
const bypassMethods = [
// Getters and internal methods
'get', 'getService', 'getCharacteristic',
// State accessors
'deviceStatus', 'speed', 'brightness', 'colorTemp',
// Event handlers
'onSet', 'onGet', 'addListener', 'removeListener',
// Controlled interval methods
'update'
];
// Methods that should only be rate limited (no debouncing)
const noDebounceAPIMethods = [
'ignored'
];
if (bypassMethods.includes(methodName)) {
return value.apply(target, args);
}
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
export const createRateLimitedVeSync = (
username: string,
password: string,
timeZone: string,
debug?: boolean,
redact?: boolean,
apiUrl?: string,
customLogger?: PluginLogger,
exclusions?: DeviceExclusion,
config?: {
countryCode?: string;
quotaManagement?: {
enabled: boolean;
bufferPercentage?: number;
priorityMethods?: string[];
}
}
): VeSync => {
// Use the new options object pattern for VeSync constructor
const client = new (VeSync as any)(username, password, timeZone, {
debug: debug,
redact: redact,
apiUrl: apiUrl,
customLogger: customLogger,
excludeConfig: exclusions,
countryCode: config?.countryCode || 'US' // Default to US
});
const logger = customLogger || new PluginLogger(console as any, debug || false);
// Create rate limiter with quota management if enabled
const quotaConfig = config?.quotaManagement?.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 as any).updateQuotaDeviceCount = (count: number) => {
rateLimiter.updateDeviceCount(count);
};
return proxy;
};