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
JavaScript
;
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