homebridge-tsvesync
Version:
Homebridge plugin for VeSync devices including Levoit air purifiers, humidifiers, and Etekcity smart outlets
415 lines • 19.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TSVESyncPlatform = void 0;
const settings_1 = require("./settings");
const device_factory_1 = require("./utils/device-factory");
const logger_1 = require("./utils/logger");
const api_proxy_1 = require("./utils/api-proxy");
/**
* HomebridgePlatform
* This class is the main constructor for your plugin, this is where you should
* parse the user config and discover/register accessories with Homebridge.
*/
class TSVESyncPlatform {
constructor(log, config, api) {
this.log = log;
this.config = config;
this.api = api;
this.Service = this.api.hap.Service;
this.Characteristic = this.api.hap.Characteristic;
// this is used to track restored cached accessories
this.accessories = [];
this.deviceAccessories = new Map();
this.lastLoginAttempt = new Date(0);
this.loginBackoffTime = 10000; // Start with 10 seconds
this.isInitialized = false;
this.TOKEN_EXPIRY = 60 * 60 * 1000; // 1 hour in milliseconds
this.lastTokenRefresh = new Date(0);
// Create initialization promise
this.initializationPromise = new Promise((resolve) => {
this.initializationResolver = resolve;
});
// Get config values with defaults
this.updateInterval = config.updateInterval || 30;
this.debug = config.debug || false;
// Initialize logger
this.logger = new logger_1.PluginLogger(this.log, this.debug);
// Validate configuration
if (!config.username || !config.password) {
this.logger.error('Missing required configuration. Please check your config.json');
return;
}
// Initialize VeSync client with all configuration
this.client = (0, api_proxy_1.createRateLimitedVeSync)(config.username, config.password, Intl.DateTimeFormat().resolvedOptions().timeZone, this.debug, true, // redact sensitive info
config.apiUrl, this.logger, config.exclude, { quotaManagement: config.quotaManagement || { enabled: true } });
this.logger.debug('Initialized platform with config:', {
name: config.name,
username: config.username,
updateInterval: this.updateInterval,
debug: this.debug,
apiUrl: config.apiUrl,
});
this.logger.info('Finished initializing platform:', this.config.name);
// When this event is fired it means Homebridge has restored all cached accessories from disk.
this.api.on('didFinishLaunching', async () => {
this.logger.debug('Executed didFinishLaunching callback');
try {
// Initialize platform
await this.initializePlatform();
// Set up device update interval - default is 30 seconds, but we'll increase it to reduce API calls
const effectiveUpdateInterval = Math.max(this.updateInterval, 120); // Minimum 2 minutes (120 seconds)
this.deviceUpdateInterval = setInterval(() => {
this.updateDeviceStates();
}, effectiveUpdateInterval * 1000);
if (effectiveUpdateInterval > this.updateInterval) {
this.logger.warn(`Increased update interval from ${this.updateInterval} to ${effectiveUpdateInterval} seconds to reduce API calls and prevent quota exhaustion`);
}
}
catch (error) {
this.logger.error('Failed to initialize platform:', error);
// Ensure initialization is resolved even on error
this.isInitialized = true;
this.initializationResolver();
}
});
// Clean up when shutting down
this.api.on('shutdown', () => {
if (this.deviceUpdateInterval) {
clearInterval(this.deviceUpdateInterval);
}
});
}
/**
* Check if platform is ready
*/
async isReady() {
if (!this.isInitialized) {
try {
// Add a 30 second timeout to prevent infinite waiting
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Platform initialization timed out')), 30000);
});
await Promise.race([this.initializationPromise, timeoutPromise]);
}
catch (error) {
this.logger.error('Platform initialization failed:', error);
// Force initialization state to true to prevent further waiting
this.isInitialized = true;
this.initializationResolver();
throw error;
}
}
}
/**
* Initialize the platform
*/
async initializePlatform() {
try {
// Try to login first
if (!await this.ensureLogin()) {
throw new Error('Failed to login to VeSync API');
}
// Get devices from API
await this.client.update();
// Discover devices
await this.discoverDevices();
// Mark as initialized before initializing accessories
this.isInitialized = true;
this.initializationResolver();
// Initialize all accessories
const initPromises = Array.from(this.deviceAccessories.entries()).map(([uuid, accessory]) => {
var _a;
const deviceName = ((_a = this.accessories.find(acc => acc.UUID === uuid)) === null || _a === void 0 ? void 0 : _a.displayName) || uuid;
return accessory.initialize().catch(error => {
this.logger.error(`Failed to initialize accessory ${deviceName}:`, error);
});
});
await Promise.all(initPromises);
}
catch (error) {
this.logger.error('Failed to initialize platform:', error);
// Still resolve the promise to allow retries during polling
this.isInitialized = true;
this.initializationResolver();
throw error;
}
}
/**
* This function is invoked when homebridge restores cached accessories from disk at startup.
*/
configureAccessory(accessory) {
this.logger.info('Loading accessory from cache:', accessory.displayName);
this.accessories.push(accessory);
}
/**
* Get all devices from all categories
*/
getAllDevices() {
return [
...this.client.fans,
...this.client.outlets,
...this.client.switches,
...this.client.bulbs,
];
}
/**
* Create a serializable device context
*/
createDeviceContext(device) {
return {
cid: device.cid,
deviceName: device.deviceName.trim(),
deviceStatus: device.deviceStatus,
deviceType: device.deviceType,
deviceRegion: device.deviceRegion,
uuid: device.uuid,
configModule: device.configModule,
macId: device.macId,
deviceCategory: device.deviceCategory,
connectionStatus: device.connectionStatus,
details: device.details || {},
config: device.config || {}
};
}
/**
* Ensure client is logged in, but avoid unnecessary logins
*/
async ensureLogin(forceLogin = false) {
var _a;
// Check if token needs refresh
const timeSinceLastRefresh = Date.now() - this.lastTokenRefresh.getTime();
if (!forceLogin && timeSinceLastRefresh < this.TOKEN_EXPIRY) {
return true; // Token is still valid
}
let isLoggedIn = false;
while (!isLoggedIn) { // Keep trying until successful
try {
// Check if we need to wait for backoff
const timeSinceLastAttempt = Date.now() - this.lastLoginAttempt.getTime();
if (timeSinceLastAttempt < this.loginBackoffTime) {
const waitTime = this.loginBackoffTime - timeSinceLastAttempt;
this.logger.debug(`Waiting ${waitTime}ms before next login attempt (backoff)`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
// Need to login again
this.logger.debug(forceLogin ? 'Forcing new login to VeSync API' : 'Refreshing VeSync API token');
this.lastLoginAttempt = new Date();
const loginResult = await this.client.login();
if (!loginResult) {
this.logger.error('Login failed - invalid credentials or API error');
this.loginBackoffTime = Math.min(this.loginBackoffTime * 2, 300000);
continue; // Try again after backoff
}
// Reset backoff and update token refresh time on successful login
this.loginBackoffTime = 10000;
this.lastTokenRefresh = new Date();
isLoggedIn = true;
return true;
}
catch (error) {
// Handle specific errors
const errorObj = error;
const errorMsg = ((_a = errorObj === null || errorObj === void 0 ? void 0 : errorObj.error) === null || _a === void 0 ? void 0 : _a.msg) || (errorObj === null || errorObj === void 0 ? void 0 : errorObj.msg) || String(error);
if (errorMsg.includes('Not logged in')) {
this.logger.debug('Session expired, forcing new login');
this.loginBackoffTime = Math.min(this.loginBackoffTime, 5000);
continue; // Try again after backoff
}
// Increase backoff time exponentially, max 5 minutes
this.loginBackoffTime = Math.min(this.loginBackoffTime * 2, 300000);
this.logger.error('Login error:', error);
continue; // Try again after backoff
}
}
return true; // This line will never be reached but TypeScript needs it
}
/**
* Update device states periodically
*/
async updateDeviceStates() {
await this.discoverDevices();
}
/**
* Check if a device should be excluded based on configuration
*/
shouldExcludeDevice(device) {
var _a, _b, _c, _d, _e;
const exclude = this.config.exclude;
if (!exclude) {
return false;
}
// Check device type
if ((_a = exclude.type) === null || _a === void 0 ? void 0 : _a.includes(device.deviceType.toLowerCase())) {
this.logger.debug(`Excluding device ${device.deviceName} by type: ${device.deviceType}`);
return true;
}
// Check device model
if ((_b = exclude.model) === null || _b === void 0 ? void 0 : _b.some(model => device.deviceType.toUpperCase().includes(model.toUpperCase()))) {
this.logger.debug(`Excluding device ${device.deviceName} by model: ${device.deviceType}`);
return true;
}
// Check exact name match
if ((_c = exclude.name) === null || _c === void 0 ? void 0 : _c.includes(device.deviceName.trim())) {
this.logger.debug(`Excluding device ${device.deviceName} by exact name match`);
return true;
}
// Check name patterns
if (exclude.namePattern) {
for (const pattern of exclude.namePattern) {
try {
const regex = new RegExp(pattern);
if (regex.test(device.deviceName.trim())) {
this.logger.debug(`Excluding device ${device.deviceName} by name pattern: ${pattern}`);
return true;
}
}
catch (error) {
this.logger.warn(`Invalid regex pattern in exclude config: ${pattern}`);
}
}
}
// Check device ID (cid or uuid)
if (((_d = exclude.id) === null || _d === void 0 ? void 0 : _d.includes(device.cid)) || ((_e = exclude.id) === null || _e === void 0 ? void 0 : _e.includes(device.uuid))) {
this.logger.debug(`Excluding device ${device.deviceName} by ID: ${device.cid}/${device.uuid}`);
return true;
}
return false;
}
/**
* This function discovers and registers your devices as accessories
*/
async discoverDevices() {
this.logger.debug('Discovering devices');
try {
// Try to login first
if (!await this.ensureLogin()) {
return;
}
let retryCount = 0;
let success = false;
// Keep retrying API calls
while (!success) {
try {
// Update device data from API
await this.client.update();
success = true;
}
catch (error) {
retryCount++;
const backoffTime = Math.min(10000 * Math.pow(2, retryCount), 300000);
this.logger.warn(`API call failed, retry attempt ${retryCount}. Waiting ${backoffTime / 1000} seconds...`);
await new Promise(resolve => setTimeout(resolve, backoffTime));
// Try to ensure we're still logged in before next attempt
if (!await this.ensureLogin()) {
continue;
}
}
}
// Get all devices
const devices = this.getAllDevices().filter(device => !this.shouldExcludeDevice(device));
// Update quota manager with device count
if (typeof this.client.updateQuotaDeviceCount === 'function') {
this.client.updateQuotaDeviceCount(devices.length);
this.logger.debug(`Updated quota manager with ${devices.length} devices`);
}
// Track processed devices for cleanup
const processedDeviceUUIDs = new Set();
// Loop over the discovered devices and register each one
for (const device of devices) {
// Generate a unique id for the accessory
const uuid = this.generateDeviceUUID(device);
processedDeviceUUIDs.add(uuid);
// Check if an accessory already exists
let accessory = this.accessories.find(acc => acc.UUID === uuid);
if (accessory) {
// Accessory already exists
this.logger.debug('Restoring existing accessory from cache:', device.deviceName);
// Update the accessory context
accessory.context.device = this.createDeviceContext(device);
// Update accessory
this.api.updatePlatformAccessories([accessory]);
}
else {
// Create a new accessory
this.logger.info('Adding new accessory:', device.deviceName);
// Create the accessory
accessory = new this.api.platformAccessory(device.deviceName, uuid, device_factory_1.DeviceFactory.getAccessoryCategory(device.deviceType));
// Store device information in context
accessory.context.device = this.createDeviceContext(device);
}
// Create the accessory handler
const deviceAccessory = device_factory_1.DeviceFactory.createAccessory(this, accessory, device);
this.deviceAccessories.set(uuid, deviceAccessory);
// Register new accessories
if (!this.accessories.find(acc => acc.UUID === uuid)) {
this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
this.accessories.push(accessory);
}
// Try to update device state with retries
let stateRetryCount = 0;
let stateSuccess = false;
while (!stateSuccess && stateRetryCount < 3) { // Limit retries to 3 attempts
try {
await deviceAccessory.syncDeviceState();
stateSuccess = true;
}
catch (error) {
stateRetryCount++;
// Only retry if we haven't hit the limit
if (stateRetryCount < 3) {
const backoffTime = Math.min(5000 * Math.pow(2, stateRetryCount), 30000);
this.logger.warn(`Failed to sync device state for ${device.deviceName}, retry attempt ${stateRetryCount}. Waiting ${backoffTime / 1000} seconds...`);
await new Promise(resolve => setTimeout(resolve, backoffTime));
// Try to ensure we're still logged in before next attempt
if (!await this.ensureLogin()) {
continue;
}
}
else {
this.logger.error(`Failed to sync device state for ${device.deviceName} after ${stateRetryCount} attempts. Skipping.`);
// Continue with other devices even if this one fails
break;
}
}
}
}
// Remove platform accessories that no longer exist or are now excluded
this.accessories
.filter(accessory => !processedDeviceUUIDs.has(accessory.UUID))
.forEach(accessory => {
this.logger.info('Removing existing accessory:', accessory.displayName);
try {
// Remove from platform's accessories array first
const index = this.accessories.indexOf(accessory);
if (index > -1) {
this.accessories.splice(index, 1);
}
// Then try to unregister from the bridge
this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
}
catch (error) {
this.logger.debug(`Failed to unregister accessory ${accessory.displayName}, it may have already been removed:`, error);
}
// Always clean up the device accessory handler
this.deviceAccessories.delete(accessory.UUID);
});
}
catch (error) {
this.logger.error('Failed to discover devices:', error);
}
}
/**
* Generate a consistent UUID for a device
* @param device The device to generate a UUID for
* @returns The generated UUID string
*/
generateDeviceUUID(device) {
let id = device.cid;
if (device.isSubDevice && device.subDeviceNo !== undefined) {
id = `${device.cid}_${device.subDeviceNo}`;
}
return this.api.hap.uuid.generate(id);
}
}
exports.TSVESyncPlatform = TSVESyncPlatform;
//# sourceMappingURL=platform.js.map