UNPKG

node-red-contrib-tplink-tapo-connect-api

Version:

This unofficial node-RED node allows connection to TP-Link Tapo devices. This project has been enhanced with AI support to enable new features. Starting with v0.50, we have added support for the KLAP protocol. To prioritize the operation of this node, we

359 lines 16.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.DeviceControlService = void 0; const retry_options_1 = require("../types/retry-options"); const retry_utils_1 = require("../utils/retry-utils"); const device_factory_1 = require("../factory/device-factory"); const device_common_1 = require("../types/device-common"); const device_types_1 = require("../types/device-types"); const bulb_1 = require("../types/bulb"); /** * Service class for handling individual device control operations * Encapsulates device interaction logic with proper error handling and resource management */ class DeviceControlService { /** * Get or create a cached device instance for session continuity */ static async getOrCreateDevice(targetIp, credentials, methodHint) { const cacheKey = `${targetIp}-${credentials.username}`; const now = Date.now(); // Start cleanup timer if not already running if (!this.cleanupInterval) { this.startCleanupTimer(); } // Check if we have a valid cached device const cached = this.deviceCache.get(cacheKey); if (cached && (now - cached.lastUsed) < this.DEVICE_CACHE_TTL) { // Update last used time cached.lastUsed = now; // Verify device is still connected try { if (cached.device.isConnected && cached.device.isConnected()) { console.log(`Using cached device for ${targetIp}`); return cached.device; } } catch (error) { console.log(`Cached device connection invalid, creating new one: ${error}`); } } // Create new device instance console.log(`Creating new device instance for ${targetIp}`); const device = await device_factory_1.DeviceFactory.createDevice(targetIp, credentials, methodHint); await device.connect(); // Cache the device this.deviceCache.set(cacheKey, { device: device, lastUsed: now, credentials: credentials }); return device; } /** * Start cleanup timer for expired cached devices */ static startCleanupTimer() { this.cleanupInterval = setInterval(() => { this.cleanupExpiredDevices(); }, 60000); // Check every minute } /** * Clean up expired cached devices */ static cleanupExpiredDevices() { const now = Date.now(); const expiredKeys = []; for (const [key, cached] of this.deviceCache.entries()) { if ((now - cached.lastUsed) >= this.DEVICE_CACHE_TTL) { expiredKeys.push(key); // Safely disconnect the device this.safeDisconnect(cached.device).catch(() => { // Ignore disconnect errors during cleanup }); } } // Remove expired entries for (const key of expiredKeys) { this.deviceCache.delete(key); console.log(`Cleaned up expired device cache for ${key}`); } // Stop cleanup timer if no cached devices remain if (this.deviceCache.size === 0 && this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } } /** * Clear all cached devices (useful for testing or cleanup) */ static clearDeviceCache() { for (const [_, cached] of this.deviceCache.entries()) { this.safeDisconnect(cached.device).catch(() => { // Ignore disconnect errors during cleanup }); } this.deviceCache.clear(); if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } } /** * Get device information including energy usage data for supported devices */ static async getDeviceInfo(email, password, targetIp, retryOptions) { const retryConfig = (0, retry_options_1.createRetryConfig)('infoRetrieval', retryOptions); const operation = async () => { try { const credentials = { username: email, password: password }; // Use lightweight generic method to get device info directly const tapoDeviceInfo = await device_factory_1.DeviceFactory.getDeviceInfo(targetIp, credentials); // Check if device supports energy monitoring and add energy usage data let tapoEnergyUsage = undefined; const deviceType = (0, device_common_1.inferTapoDeviceType)(tapoDeviceInfo); console.log(`Device model: "${tapoDeviceInfo.model}", Inferred type: "${deviceType}", Energy monitoring models: [${device_types_1.energyMonitoringModels.join(', ')}]`); if (deviceType !== 'UNKNOWN' && device_types_1.energyMonitoringModels.includes(deviceType)) { try { const device = await device_factory_1.DeviceFactory.createDevice(targetIp, credentials, 'getEnergyUsage'); await device.connect(); tapoEnergyUsage = await device.getEnergyUsage(); await device.disconnect(); } catch (energyError) { // Energy usage retrieval failed, but don't fail the entire operation console.log(`Warning: Could not retrieve energy usage for ${tapoDeviceInfo.model}: ${energyError}`); } } return { result: true, tapoDeviceInfo: tapoDeviceInfo, tapoEnergyUsage: tapoEnergyUsage }; } catch (error) { throw error; } }; return this.executeWithRetry(operation, retryConfig, 'getDeviceInfo'); } /** * Get energy usage information for devices that support energy monitoring */ static async getEnergyUsage(email, password, targetIp, retryOptions) { const retryConfig = (0, retry_options_1.createRetryConfig)('energyMonitoring', retryOptions); const operation = async () => { try { const credentials = { username: email, password: password }; // First, get device information to check energy monitoring support const deviceInfo = await device_factory_1.DeviceFactory.getDeviceInfo(targetIp, credentials); // Check if device supports energy monitoring const deviceType = (0, device_common_1.inferTapoDeviceType)(deviceInfo); if (deviceType === 'UNKNOWN') { return { result: false, tapoDeviceInfo: deviceInfo, errorInf: new Error(`Unknown device type. Cannot determine energy monitoring support for device model: ${deviceInfo.model}`) }; } if (!device_types_1.energyMonitoringModels.includes(deviceType)) { return { result: false, tapoDeviceInfo: deviceInfo, errorInf: new Error(`Device type ${deviceType} does not support energy monitoring. Supported models: ${device_types_1.energyMonitoringModels.join(', ')}`) }; } // Device supports energy monitoring, proceed to get energy data const device = await device_factory_1.DeviceFactory.createDevice(targetIp, credentials, 'getEnergyUsage'); await device.connect(); try { const tapoEnergyUsage = await device.getEnergyUsage(); if (this.isEmpty(tapoEnergyUsage)) { return { result: false, tapoDeviceInfo: deviceInfo, errorInf: new Error("Energy usage data not found.") }; } return { result: true, tapoDeviceInfo: deviceInfo, tapoEnergyUsage: tapoEnergyUsage }; } finally { await this.safeDisconnect(device); } } catch (error) { // Handle unexpected errors (network, authentication, etc.) try { const credentials = { username: email, password: password }; const deviceInfo = await device_factory_1.DeviceFactory.getDeviceInfo(targetIp, credentials); return { result: false, tapoDeviceInfo: deviceInfo, errorInf: error }; } catch (deviceInfoError) { return { result: false, errorInf: error }; } } }; return this.executeWithRetry(operation, retryConfig, 'getEnergyUsage'); } /** * Turn device on */ static async turnOn(email, password, targetIp, retryOptions) { const retryConfig = (0, retry_options_1.createRetryConfig)('deviceControl', retryOptions); const operation = async () => { try { const credentials = { username: email, password: password }; const device = await this.getOrCreateDevice(targetIp, credentials, 'turnOn'); await device.turnOn(); const tapoDeviceInfo = await device_factory_1.DeviceFactory.getDeviceInfo(targetIp, credentials); return { result: true, tapoDeviceInfo: tapoDeviceInfo }; } catch (error) { throw error; } }; return this.executeWithRetry(operation, retryConfig, 'turnOn'); } /** * Turn device off */ static async turnOff(email, password, targetIp, retryOptions) { const retryConfig = (0, retry_options_1.createRetryConfig)('deviceControl', retryOptions); const operation = async () => { try { const credentials = { username: email, password: password }; const device = await this.getOrCreateDevice(targetIp, credentials, 'turnOff'); await device.turnOff(); const tapoDeviceInfo = await device_factory_1.DeviceFactory.getDeviceInfo(targetIp, credentials); return { result: true, tapoDeviceInfo: tapoDeviceInfo }; } catch (error) { throw error; } }; return this.executeWithRetry(operation, retryConfig, 'turnOff'); } /** * Set device brightness */ static async setBrightness(email, password, targetIp, brightness, retryOptions) { const retryConfig = (0, retry_options_1.createRetryConfig)('deviceControl', retryOptions); const operation = async () => { try { if (brightness < 1 || brightness > 100) { throw new Error("Brightness must be between 1-100"); } const credentials = { username: email, password: password }; // Get device type and check capability const deviceInfo = await device_factory_1.DeviceFactory.getDeviceInfo(targetIp, credentials); const deviceType = (0, device_common_1.inferTapoDeviceType)(deviceInfo); if (deviceType === 'UNKNOWN') { throw new Error(`Unknown device type. Cannot determine brightness control support for device model: ${deviceInfo.model} at ${targetIp}`); } if (!(0, bulb_1.supportsBrightnessControl)(deviceType)) { throw new Error(`Device type ${deviceType} at ${targetIp} does not support brightness control. This feature is only available for bulb devices.`); } const device = await this.getOrCreateDevice(targetIp, credentials, 'setBrightness'); await device.setBrightness(brightness); const tapoDeviceInfo = await device_factory_1.DeviceFactory.getDeviceInfo(targetIp, credentials); return { result: true, tapoDeviceInfo: tapoDeviceInfo }; } catch (error) { throw error; } }; return this.executeWithRetry(operation, retryConfig, 'setBrightness'); } /** * Set device color using named color */ static async setColor(email, password, targetIp, colour, retryOptions) { const retryConfig = (0, retry_options_1.createRetryConfig)('deviceControl', retryOptions); const operation = async () => { try { if (colour === "") { throw new Error("Color value cannot be empty"); } const credentials = { username: email, password: password }; // Get device type and check capability const deviceInfo = await device_factory_1.DeviceFactory.getDeviceInfo(targetIp, credentials); const deviceType = (0, device_common_1.inferTapoDeviceType)(deviceInfo); if (deviceType === 'UNKNOWN') { throw new Error(`Unknown device type. Cannot determine color control support for device model: ${deviceInfo.model} at ${targetIp}`); } if (!(0, bulb_1.supportsColorControl)(deviceType)) { throw new Error(`Device type ${deviceType} at ${targetIp} does not support color control. This feature is only available for color bulb devices.`); } const device = await this.getOrCreateDevice(targetIp, credentials, 'setColor'); await device.setNamedColor(colour); const tapoDeviceInfo = await device_factory_1.DeviceFactory.getDeviceInfo(targetIp, credentials); return { result: true, tapoDeviceInfo: tapoDeviceInfo }; } catch (error) { throw error; } }; return this.executeWithRetry(operation, retryConfig, 'setColor'); } /** * Execute operation with retry logic */ static async executeWithRetry(operation, retryConfig, operationName) { if (retryConfig) { const retryHandler = new retry_utils_1.TapoRetryHandler(retryConfig); const result = await retryHandler.execute(operation, operationName); if (result.success) { return result.data; } else { return { result: false, errorInf: result.error }; } } else { try { return await operation(); } catch (error) { return { result: false, errorInf: error }; } } } /** * Safely disconnect device */ static async safeDisconnect(device) { if (device && typeof device.disconnect === 'function') { try { await device.disconnect(); } catch (disconnectError) { // Ignore disconnect errors } } } /** * Check if object is empty */ static isEmpty(obj) { return !Object.keys(obj).length; } } exports.DeviceControlService = DeviceControlService; /** Device instance cache for session continuity */ DeviceControlService.deviceCache = new Map(); /** Cache TTL in milliseconds (5 minutes) */ DeviceControlService.DEVICE_CACHE_TTL = 300000; /** Cleanup interval for cached devices */ DeviceControlService.cleanupInterval = null; //# sourceMappingURL=device-control-service.js.map