UNPKG

homebridge-switchbot-ble

Version:

SwitchBot BLE plugin for Homebridge using direct BLE control. Features device mode verification for ON/OFF commands and improved retry logic. All logs use local time (new Date().toLocaleString()).

573 lines (489 loc) 28.7 kB
const fs = require('fs'); const path = require('path'); const SwitchBotAPI = require('homebridge-switchbot-ble/switchbot-api-commonjs'); const { exec } = require('child_process'); let Service, Characteristic; // Create a log directory if it doesn't exist const LOG_DIR = path.join(__dirname, 'logs'); if (!fs.existsSync(LOG_DIR)) { try { fs.mkdirSync(LOG_DIR); } catch (err) { console.error('Failed to create log directory:', err.message); } } // Log helper function function logToFile(message, level = 'INFO') { try { const today = new Date().toLocaleDateString().replace(/\//g, '-'); const logFile = path.join(LOG_DIR, `switchbot-api-${today}.log`); // 手动构建与示例完全匹配的格式 [2025/5/30 下午6:20:45][DEBUG] const now = new Date(); const year = now.getFullYear(); const month = now.getMonth() + 1; const day = now.getDate(); const hours = now.getHours(); const minutes = now.getMinutes().toString().padStart(2, '0'); const seconds = now.getSeconds().toString().padStart(2, '0'); const ampm = hours >= 12 ? '下午' : '上午'; const hour12 = hours % 12 || 12; // 完全匹配示例格式 const timestamp = `[${year}/${month}/${day} ${ampm}${hour12}:${minutes}:${seconds}]`; const logPrefix = `${timestamp}[${level}]`; const logMessage = `${logPrefix} ${message}\n`; fs.appendFileSync(logFile, logMessage); } catch (err) { console.error('Failed to write to log file:', err.message); } } // 使用相同的格式创建时间戳 function formatTimestamp() { const now = new Date(); const year = now.getFullYear(); const month = now.getMonth() + 1; const day = now.getDate(); const hours = now.getHours(); const minutes = now.getMinutes().toString().padStart(2, '0'); const seconds = now.getSeconds().toString().padStart(2, '0'); const ampm = hours >= 12 ? '下午' : '上午'; const hour12 = hours % 12 || 12; return `[${year}/${month}/${day} ${ampm}${hour12}:${minutes}:${seconds}]`; } class SwitchbotAccessory { constructor(log, config, api) { this.log = log; this.config = config; this.api = api; // Configuration this.name = config.name || 'SwitchBot'; this.mode = config.mode || 'switch'; // Changed default to 'switch' instead of 'press' this.deviceId = config.deviceId; this.configPath = config.configPath || path.join(process.env.HOME || process.env.USERPROFILE, '.switchbot.config'); this.autoOff = config.autoOff || false; this.autoOffDelay = config.autoOffDelay || 1; // seconds this.debug = config.debug || false; // 增加debug選項 this.enableStatusCheck = config.enableStatusCheck || false; this.statusCheckInterval = config.statusCheckInterval || 60; // seconds, default 60s // Track current state this.currentState = false; // Default to OFF this.statusCheckTimer = null; // 用于存储定时器句柄 // Child Bridge 相關配置 this.logPrefix = `[${this.name}] `; // Get Service and Characteristic from the API Service = this.api.hap.Service; Characteristic = this.api.hap.Characteristic; // Load saved device ID from config file if not provided in config if (!this.deviceId) { try { if (fs.existsSync(this.configPath)) { const savedConfig = JSON.parse(fs.readFileSync(this.configPath, 'utf8')); this.deviceId = savedConfig.deviceId; this.log.info(`${this.logPrefix}Loaded device ID from config: ${this.deviceId}`); logToFile(`${this.logPrefix}Loaded device ID from config: ${this.deviceId}`); } } catch (error) { this.log.error(`${this.logPrefix}Error loading config: ${error.message}`); logToFile(`${this.logPrefix}Error loading config: ${error.message}`, 'ERROR'); } } else { // Save the device ID to config file try { fs.writeFileSync(this.configPath, JSON.stringify({ deviceId: this.deviceId }), 'utf8'); this.log.info(`${this.logPrefix}Saved device ID to config: ${this.deviceId}`); logToFile(`${this.logPrefix}Saved device ID to config: ${this.deviceId}`); } catch (error) { this.log.error(`${this.logPrefix}Error saving config: ${error.message}`); logToFile(`${this.logPrefix}Error saving config: ${error.message}`, 'ERROR'); } } // Create the switch service this.switchService = new Service.Switch(this.name); // Add the On characteristic this.switchService .getCharacteristic(Characteristic.On) .onGet(this.getState.bind(this)) .onSet(this.setState.bind(this)); this.informationService = new Service.AccessoryInformation() .setCharacteristic(Characteristic.Manufacturer, 'SwitchBot') .setCharacteristic(Characteristic.Model, 'Bot') .setCharacteristic(Characteristic.SerialNumber, this.deviceId || 'Unknown'); this.log.info(`${this.logPrefix}SwitchBot initialized with device ID: ${this.deviceId || 'Not configured'}`); logToFile(`${this.logPrefix}SwitchBot initialized with device ID: ${this.deviceId || 'Not configured'}`); // Enhanced debug info if (this.debug) { this.log.info(`${this.logPrefix}Debug mode: ON`); logToFile(`${this.logPrefix}Debug mode: ON`); this.logDeviceDetails(); } // 如果启用了状态检查,开始定时检查 if (this.enableStatusCheck && this.deviceId) { this.startStatusCheckTimer(); this.log.info(`${this.logPrefix}Automatic status check enabled, interval: ${this.statusCheckInterval}s`); logToFile(`${this.logPrefix}Automatic status check enabled, interval: ${this.statusCheckInterval}s`); } } // 增加詳細設備信息日誌 logDeviceDetails() { this.log.debug(`${this.logPrefix}Configuration details:`); this.log.debug(`${this.logPrefix} Name: ${this.name}`); this.log.debug(`${this.logPrefix} Device ID: ${this.deviceId}`); this.log.debug(`${this.logPrefix} Mode: ${this.mode}`); this.log.debug(`${this.logPrefix} Auto Off: ${this.autoOff}`); this.log.debug(`${this.logPrefix} Auto Off Delay: ${this.autoOffDelay}s`); this.log.debug(`${this.logPrefix} Debug Mode: ${this.debug}`); this.log.debug(`${this.logPrefix} Status Check: ${this.enableStatusCheck}`); this.log.debug(`${this.logPrefix} Status Check Interval: ${this.statusCheckInterval}s`); this.log.debug(`${this.logPrefix} Current State: ${this.currentState ? 'ON' : 'OFF'}`); // Also log to file logToFile(`${this.logPrefix}Configuration details:`); logToFile(`${this.logPrefix} Name: ${this.name}`); logToFile(`${this.logPrefix} Device ID: ${this.deviceId}`); logToFile(`${this.logPrefix} Mode: ${this.mode}`); logToFile(`${this.logPrefix} Auto Off: ${this.autoOff}`); logToFile(`${this.logPrefix} Auto Off Delay: ${this.autoOffDelay}s`); logToFile(`${this.logPrefix} Debug Mode: ${this.debug}`); logToFile(`${this.logPrefix} Status Check: ${this.enableStatusCheck}`); logToFile(`${this.logPrefix} Status Check Interval: ${this.statusCheckInterval}s`); logToFile(`${this.logPrefix} Current State: ${this.currentState ? 'ON' : 'OFF'}`); } // Enhanced logging helper debugLog(message) { if (this.debug) { this.log.debug(`${this.logPrefix}${message}`); logToFile(`${this.logPrefix}${message}`, 'DEBUG'); } } async getState() { this.log.debug(`${this.logPrefix}Getting current state: ${this.currentState ? 'ON' : 'OFF'}`); logToFile(`${this.logPrefix}Getting current state: ${this.currentState ? 'ON' : 'OFF'}`, 'DEBUG'); if (this.debug) { try { const serverStatus = await SwitchBotAPI.getServerStatus(); this.debugLog(`Server status: Platform=${serverStatus.platform}, Admin=${serverStatus.adminRights}`); // 嘗試獲取設備狀態 this.debugLog(`Current tracked state for ${this.deviceId}: ${this.currentState ? 'ON' : 'OFF'}`); } catch (error) { this.debugLog(`Error getting status: ${error.message}`); logToFile(`${this.logPrefix}Error getting status: ${error.message}`, 'ERROR'); } } // Return the tracked state return this.currentState; } async setState(value) { const setStateTime = formatTimestamp(); this.log.info(`${this.logPrefix}${setStateTime} Setting switch state to ${value ? 'ON' : 'OFF'}`); logToFile(`${this.logPrefix}Setting switch state to ${value ? 'ON' : 'OFF'}`); // 增加詳細調試日誌 this.log.debug(`${this.logPrefix}DEBUG: setState called with value=${value}`); this.log.debug(`${this.logPrefix}DEBUG: Operation mode=${this.mode}, Device ID=${this.deviceId}`); logToFile(`${this.logPrefix}DEBUG: setState called with value=${value}`, 'DEBUG'); logToFile(`${this.logPrefix}DEBUG: Operation mode=${this.mode}, Device ID=${this.deviceId}`, 'DEBUG'); if (!this.deviceId) { this.log.error(`${this.logPrefix}No device ID configured`); logToFile(`${this.logPrefix}No device ID configured`, 'ERROR'); // Always update the state even if we have an error, to prevent HomeKit timeouts this.currentState = value; return this.currentState; } this.debugLog(`Attempting to control device: ${this.deviceId}`); // Add a safety timeout to ensure we always return within Homebridge's expected timeframe const commandTimeout = 8000; // 8 seconds max execution time let timeoutId; try { // Create a promise that will reject after the timeout const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error('Command execution timed out')); }, commandTimeout); }); // Create the actual command execution promise const executionPromise = (async () => { let result; let commandType = ''; // 用於記錄命令類型 // 使用直接的命令來控制設備 const cmdPath = path.join(__dirname, 'bot-cmd.mjs'); if (this.mode === 'press') { // In press mode, we always just press the button this.log.info(`${this.logPrefix}Pressing button for device: ${this.deviceId}`); commandType = 'press'; const cmdLine = `node "${cmdPath}" press ${this.deviceId}`; this.log.info(`${this.logPrefix}Executing command: ${cmdLine}`); logToFile(`${this.logPrefix}Pressing button for device: ${this.deviceId}`, 'COMMAND'); logToFile(`${this.logPrefix}Executing command: ${cmdLine}`, 'COMMAND'); result = await this.executeCommand(cmdLine); this.log.debug(`${this.logPrefix}DEBUG: Press command completed`); logToFile(`${this.logPrefix}DEBUG: Press command completed`, 'DEBUG'); // For press mode, we update the state temporarily to what was requested this.currentState = value; } else if (this.mode === 'switch' || this.mode === 'on' || this.mode === 'off') { // In switch mode, we turn on or off based on the requested state if (value) { this.log.info(`${this.logPrefix}Turning ON device: ${this.deviceId}`); commandType = 'on'; const cmdLine = `node "${cmdPath}" on ${this.deviceId}`; this.log.info(`${this.logPrefix}Executing command: ${cmdLine}`); logToFile(`${this.logPrefix}Turning ON device: ${this.deviceId}`, 'COMMAND'); logToFile(`${this.logPrefix}Executing command: ${cmdLine}`, 'COMMAND'); result = await this.executeCommand(cmdLine); this.log.debug(`${this.logPrefix}DEBUG: On command completed`); logToFile(`${this.logPrefix}DEBUG: On command completed`, 'DEBUG'); // Update tracked state this.currentState = true; logToFile(`${this.logPrefix}State updated to: ON`, 'STATE'); } else { this.log.info(`${this.logPrefix}Turning OFF device: ${this.deviceId}`); commandType = 'off'; const cmdLine = `node "${cmdPath}" off ${this.deviceId}`; this.log.info(`${this.logPrefix}Executing command: ${cmdLine}`); logToFile(`${this.logPrefix}Turning OFF device: ${this.deviceId}`, 'COMMAND'); logToFile(`${this.logPrefix}Executing command: ${cmdLine}`, 'COMMAND'); result = await this.executeCommand(cmdLine); this.log.debug(`${this.logPrefix}DEBUG: Off command completed`); logToFile(`${this.logPrefix}DEBUG: Off command completed`, 'DEBUG'); // Update tracked state this.currentState = false; logToFile(`${this.logPrefix}State updated to: OFF`, 'STATE'); } } else { // Default behavior for unknown modes if (value) { this.log.info(`${this.logPrefix}Default ON for device: ${this.deviceId}`); commandType = 'on'; const cmdLine = `node "${cmdPath}" on ${this.deviceId}`; this.log.info(`${this.logPrefix}Executing command: ${cmdLine}`); logToFile(`${this.logPrefix}Default ON for device: ${this.deviceId}`, 'COMMAND'); logToFile(`${this.logPrefix}Executing command: ${cmdLine}`, 'COMMAND'); result = await this.executeCommand(cmdLine); this.currentState = true; logToFile(`${this.logPrefix}State updated to: ON`, 'STATE'); } else { this.log.info(`${this.logPrefix}Default OFF for device: ${this.deviceId}`); commandType = 'off'; const cmdLine = `node "${cmdPath}" off ${this.deviceId}`; this.log.info(`${this.logPrefix}Executing command: ${cmdLine}`); logToFile(`${this.logPrefix}Default OFF for device: ${this.deviceId}`, 'COMMAND'); logToFile(`${this.logPrefix}Executing command: ${cmdLine}`, 'COMMAND'); result = await this.executeCommand(cmdLine); this.currentState = false; logToFile(`${this.logPrefix}State updated to: OFF`, 'STATE'); } } // 增強的日誌輸出 const commandCompleteTime = formatTimestamp(); this.log.debug(`${this.logPrefix}${commandCompleteTime} ${commandType} command completed`); this.log.debug(`${this.logPrefix}DEBUG: Command result: ${JSON.stringify(result)}`); this.log.debug(`${this.logPrefix}Updated state: ${this.currentState ? 'ON' : 'OFF'}`); logToFile(`${this.logPrefix}${commandType} command completed`, 'DEBUG'); // 确保安全处理JSON结果 try { logToFile(`${this.logPrefix}DEBUG: Command result: ${JSON.stringify(result)}`, 'DEBUG'); } catch (jsonError) { logToFile(`${this.logPrefix}DEBUG: Command result: [Unable to stringify result: ${jsonError.message}]`, 'ERROR'); } logToFile(`${this.logPrefix}Updated state: ${this.currentState ? 'ON' : 'OFF'}`, 'STATE'); if (result && result.success) { this.log.info(`${this.logPrefix}Command successfully sent to SwitchBot: ${commandType}`); logToFile(`${this.logPrefix}Command successfully sent to SwitchBot: ${commandType}`, 'SUCCESS'); } else if (result) { this.log.warn(`${this.logPrefix}Command sent but reported issues: ${result.error || 'unknown error'}`); logToFile(`${this.logPrefix}Command sent but reported issues: ${result.error || 'unknown error'}`, 'WARNING'); } // For "press" mode or when autoOff is enabled, always return to OFF after a delay if ((this.mode === 'press' || this.autoOff) && value) { this.log.debug(`${this.logPrefix}Auto-off scheduled in ${this.autoOffDelay} second(s)`); logToFile(`${this.logPrefix}Auto-off scheduled in ${this.autoOffDelay} second(s)`, 'DEBUG'); setTimeout(() => { const autoOffTime = formatTimestamp(); this.log.debug(`${this.logPrefix}${autoOffTime} Auto-off triggered`); logToFile(`${this.logPrefix}Auto-off triggered`, 'DEBUG'); this.currentState = false; // Update tracked state logToFile(`${this.logPrefix}State updated to: OFF (auto-off)`, 'STATE'); this.switchService.updateCharacteristic(this.Characteristic.On, false); }, this.autoOffDelay * 1000); } return this.currentState; // Return the new state })(); // Race the execution promise against the timeout const result = await Promise.race([executionPromise, timeoutPromise]); // Clear the timeout if execution finished before timeout clearTimeout(timeoutId); return result; } catch (err) { // Clear timeout if we got here through an error if (timeoutId) clearTimeout(timeoutId); // If we timeout or have another error, log it but return a valid state to prevent HomeKit timeout if (err.message === 'Command execution timed out') { this.log.error(`${this.logPrefix}Command execution timed out after ${commandTimeout}ms`); logToFile(`${this.logPrefix}Command execution timed out after ${commandTimeout}ms`, 'ERROR'); } else { this.log.error(`${this.logPrefix}Error controlling device: ${err.message}`); this.log.debug(`${this.logPrefix}DEBUG: Error stack trace: ${err.stack}`); logToFile(`${this.logPrefix}Error controlling device: ${err.message}`, 'ERROR'); logToFile(`${this.logPrefix}DEBUG: Error stack trace: ${err.stack}`, 'ERROR'); } // Even if there's an error, we update and return the state that was requested // This ensures the UI remains responsive if (this.mode !== 'press') { this.currentState = value; logToFile(`${this.logPrefix}Forced state update despite error: ${value ? 'ON' : 'OFF'}`, 'STATE'); } return this.currentState; } } // Helper to execute commands directly using child_process with timeout executeCommand(cmd) { return new Promise((resolve, reject) => { this.log.debug(`${this.logPrefix}Executing command: ${cmd}`); logToFile(`${this.logPrefix}Executing command: ${cmd}`, 'COMMAND'); // Set a timeout for the command execution (5 seconds) const cmdTimeout = setTimeout(() => { this.log.error(`${this.logPrefix}Command execution timed out: ${cmd}`); logToFile(`${this.logPrefix}Command execution timed out: ${cmd}`, 'ERROR'); resolve({ success: false, error: 'Command execution timed out', timedOut: true }); }, 5000); const childProcess = exec(cmd, { timeout: 5000 }, (error, stdout, stderr) => { // Clear the timeout as we got a response clearTimeout(cmdTimeout); if (stderr) { this.log.debug(`${this.logPrefix}Command stderr: ${stderr.trim()}`); logToFile(`${this.logPrefix}Command stderr: ${stderr.trim()}`, 'ERROR'); } if (stdout) { this.log.debug(`${this.logPrefix}Command stdout: ${stdout.trim()}`); logToFile(`${this.logPrefix}Command stdout: ${stdout.trim()}`, 'DEBUG'); } if (error) { this.log.error(`${this.logPrefix}Command error: ${error.message}`); logToFile(`${this.logPrefix}Command error: ${error.message}`, 'ERROR'); // We resolve anyway to handle errors gracefully resolve({ success: false, error: error.message, stdout, stderr }); } else { logToFile(`${this.logPrefix}Command executed successfully`, 'SUCCESS'); resolve({ success: true, stdout, stderr }); } }); // Add additional error handler for process errors childProcess.on('error', (err) => { clearTimeout(cmdTimeout); this.log.error(`${this.logPrefix}Process error: ${err.message}`); logToFile(`${this.logPrefix}Process error: ${err.message}`, 'ERROR'); resolve({ success: false, error: err.message, processError: true }); }); }); } // 添加启动定时器的函数 startStatusCheckTimer() { // 清除已有的定时器 if (this.statusCheckTimer) { clearInterval(this.statusCheckTimer); } // 确保直接记录而不是通过debugLog方法 this.log.info(`${this.logPrefix}Status check timer started with interval ${this.statusCheckInterval}s`); logToFile(`${this.logPrefix}Status check timer started with interval ${this.statusCheckInterval}s`); // 设置新定时器 this.statusCheckTimer = setInterval(() => { this.checkDeviceStatus(); }, this.statusCheckInterval * 1000); } // 停止定时器 stopStatusCheckTimer() { if (this.statusCheckTimer) { clearInterval(this.statusCheckTimer); this.statusCheckTimer = null; this.log.info(`${this.logPrefix}Status check timer stopped`); logToFile(`${this.logPrefix}Status check timer stopped`); } } // 设备状态检查函数 async checkDeviceStatus() { if (!this.deviceId) { return; } const statusCheckTime = formatTimestamp(); // 修改为总是记录状态检查,不依赖debug模式 this.log.info(`${this.logPrefix}${statusCheckTime} Running scheduled status check for device: ${this.deviceId}`); logToFile(`${this.logPrefix}Running scheduled status check for device: ${this.deviceId}`); try { // 获取设备状态 const status = await SwitchBotAPI.getBotStatus(this.deviceId); // 确保安全处理JSON let statusStr = "Unknown"; try { statusStr = JSON.stringify(status); this.debugLog(`Status check result: ${statusStr}`); } catch (jsonError) { this.log.error(`Failed to stringify status: ${jsonError.message}`); logToFile(`${this.logPrefix}Failed to stringify status: ${jsonError.message}`, 'ERROR'); } // 如果获取到了有效状态 if (status && status.state) { const deviceIsOn = status.state === 'ON'; // 无论状态是否变化,都记录当前状态 this.debugLog(`Current device state: ${deviceIsOn ? 'ON' : 'OFF'}, HomeKit state: ${this.currentState ? 'ON' : 'OFF'}`); logToFile(`${this.logPrefix}Current device state: ${deviceIsOn ? 'ON' : 'OFF'}, HomeKit state: ${this.currentState ? 'ON' : 'OFF'}`, 'DEBUG'); // 如果状态不同,更新HomeKit if (this.currentState !== deviceIsOn) { this.log.info(`${this.logPrefix}Device state changed externally: ${deviceIsOn ? 'ON' : 'OFF'}`); logToFile(`${this.logPrefix}Device state changed externally: ${deviceIsOn ? 'ON' : 'OFF'}`, 'STATE'); // 更新本地状态 this.currentState = deviceIsOn; // 更新HomeKit界面 this.switchService.updateCharacteristic(Characteristic.On, deviceIsOn); this.log.info(`${this.logPrefix}Updated HomeKit state to match device: ${deviceIsOn ? 'ON' : 'OFF'}`); logToFile(`${this.logPrefix}Updated HomeKit state to match device: ${deviceIsOn ? 'ON' : 'OFF'}`, 'STATE'); } else { // 状态未变化,记录稳定状态 this.debugLog(`Device state unchanged, remains: ${deviceIsOn ? 'ON' : 'OFF'}`); logToFile(`${this.logPrefix}Device state unchanged, remains: ${deviceIsOn ? 'ON' : 'OFF'}`, 'STATE'); } // 记录额外设备信息(如果有) if (status.battery) { this.debugLog(`Device battery: ${status.battery}%`); logToFile(`${this.logPrefix}Device battery: ${status.battery}%`, 'INFO'); } if (status.mode) { this.debugLog(`Device mode: ${status.mode}`); logToFile(`${this.logPrefix}Device mode: ${status.mode}`, 'INFO'); } } else { this.log.warn(`${this.logPrefix}Status check completed but no valid state returned`); logToFile(`${this.logPrefix}Status check completed but no valid state returned`, 'WARN'); if (status && status.error) { this.log.warn(`${this.logPrefix}Status check error: ${status.error}`); logToFile(`${this.logPrefix}Status check error: ${status.error}`, 'WARN'); } } } catch (error) { this.log.error(`${this.logPrefix}Error checking device status: ${error.message}`); logToFile(`${this.logPrefix}Error checking device status: ${error.message}`, 'ERROR'); } } getServices() { // 当插件停止时清除定时器 this.api.on('shutdown', () => { this.stopStatusCheckTimer(); this.log.info(`${this.logPrefix}Plugin shutting down, stopped status check timer`); logToFile(`${this.logPrefix}Plugin shutting down, stopped status check timer`); }); return [this.informationService, this.switchService]; } } module.exports = SwitchbotAccessory;