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()).

586 lines (505 loc) 23.2 kB
// SwitchBot API Server - Provides direct JavaScript API to control SwitchBot devices import { SwitchBotBLE } from 'node-switchbot'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); const switchbot = new SwitchBotBLE({ debug: false, scanDuration: 3000, connectTimeout: 3000, commandTimeout: 1000 }); const CACHE_DURATION = 60000; // Cache validity: 60 seconds const deviceCache = {}; // Cache object for storing device status and instances function normalizeMacAddress(mac) { if (!mac) return null; if (/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i.test(mac)) return mac.toLowerCase(); const cleanMac = mac.replace(/[^0-9a-f]/gi, ''); if (cleanMac.length === 12) return cleanMac.match(/.{1,2}/g).join(':').toLowerCase(); return mac.toLowerCase(); } export async function checkAdminRights() { try { if (process.platform === 'win32') { const { stdout } = await execAsync('powershell -command "([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"'); return stdout.trim() === 'True'; } else if (process.platform === 'linux' || process.platform === 'darwin') { const { stdout } = await execAsync('id -u'); return stdout.trim() === '0'; } return false; } catch (error) { return false; } } // Improved log output function function logWithTimestamp(level, message) { const timestamp = new Date().toLocaleString(); let prefix; switch (level) { case 'error': prefix = `[${timestamp}][ERROR]`; break; case 'warn': prefix = `[${timestamp}][WARN]`; break; case 'info': prefix = `[${timestamp}][INFO]`; break; case 'debug': prefix = `[${timestamp}][DEBUG]`; break; default: prefix = `[${timestamp}]`; } console.log(`${prefix} ${message}`); } export async function scanDevices({ duration = 10000, targetAddress = null } = {}) { let foundDevices = []; let waitPromiseResolve = null; let targetFound = false; try { logWithTimestamp('info', `Start scanning devices (duration ${duration}ms)${targetAddress ? `, targeting: ${targetAddress}` : ''}...`); switchbot.onadvertisement = (ad) => { if (!foundDevices.find(d => d.address === ad.address)) { logWithTimestamp('debug', `Received advertisement: ${ad.address} (ID: ${ad.id || 'unknown'})`); const device = { address: ad.address, id: ad.id, type: 'unknown', model: ad.serviceData?.model || 'unknown', modelName: ad.serviceData?.modelName || 'unknown' }; if (ad.serviceData) { logWithTimestamp('debug', `Device ${ad.address} service data: ${JSON.stringify(ad.serviceData)}`); if (ad.serviceData.model === 'H') { device.type = 'Bot'; device.mode = ad.serviceData.mode ? 'Switch' : 'Press'; device.state = ad.serviceData.state ? 'ON' : 'OFF'; device.battery = ad.serviceData.battery; } else if (ad.serviceData.model === 'T') { device.type = 'Meter'; device.temperature = ad.serviceData.temperature; device.humidity = ad.serviceData.humidity; device.battery = ad.serviceData.battery; } else if (ad.serviceData.model === 's') { device.type = 'Contact Sensor'; device.contact = ad.serviceData.contact; device.battery = ad.serviceData.battery; } } foundDevices.push(device); logWithTimestamp('info', `Device found: ${device.address} (${device.type})`); // If we have a target address and this device matches, stop scanning early if (targetAddress && device.address.toLowerCase() === targetAddress.toLowerCase()) { targetFound = true; logWithTimestamp('info', `Target device found: ${device.address}, stopping scan early`); if (waitPromiseResolve) { waitPromiseResolve(); } } } }; await switchbot.startScan(); logWithTimestamp('debug', 'Scan started'); // Wait for either target device found or timeout await new Promise(resolve => { waitPromiseResolve = resolve; // Still set a timeout as fallback setTimeout(() => { if (!targetFound) { logWithTimestamp('debug', 'Scan duration ended without finding target device'); } resolve(); }, duration); }); await switchbot.stopScan(); logWithTimestamp('info', `Scan complete, found ${foundDevices.length} devices`); return foundDevices; } catch (error) { logWithTimestamp('error', `Scan error: ${error.message}`); console.error(error); return foundDevices; // Return found devices even if error } } // Get specific device status via advertisement export async function getBotStatus(deviceId, { duration = 3000 } = {}) { const normalizedDeviceId = normalizeMacAddress(deviceId); if (!normalizedDeviceId) { return { deviceId: deviceId, type: 'Bot', state: null, mode: null, battery: null, error: 'Invalid device ID' }; } let deviceStatus = { deviceId: normalizedDeviceId, type: 'Bot', state: null, mode: null, battery: null }; let deviceFound = false; let waitPromiseResolve = null; try { // Set advertisement listener switchbot.onadvertisement = (ad) => { if (ad.address.toLowerCase() === normalizedDeviceId.toLowerCase()) { deviceFound = true; if (ad.serviceData && ad.serviceData.model === 'H') { deviceStatus.state = ad.serviceData.state ? 'ON' : 'OFF'; deviceStatus.mode = ad.serviceData.mode ? 'Switch' : 'Press'; deviceStatus.battery = ad.serviceData.battery; } // Once we found the device, resolve immediately if (waitPromiseResolve) { logWithTimestamp('info', `Device ${normalizedDeviceId} found, stopping scan early`); waitPromiseResolve(); } } }; // Start scan await switchbot.startScan(); logWithTimestamp('info', `Scanning for device ${normalizedDeviceId} (max ${duration}ms)...`); // Wait for either device found or timeout await new Promise(resolve => { waitPromiseResolve = resolve; // Still set a timeout as fallback setTimeout(() => { if (!deviceFound) { logWithTimestamp('debug', 'Scan duration ended without finding target device'); } resolve(); }, duration); }); // Stop scan await switchbot.stopScan(); if (!deviceFound) { deviceStatus.error = 'Device did not respond to advertisement'; } return deviceStatus; } catch (error) { console.error('Error getting status:', error.message); deviceStatus.error = error.message; return deviceStatus; } } // Try to discover device, but do not throw error async function tryDiscoverBot(deviceId, quick = true, duration = 3000, maxRetries = 5) { const normalizedDeviceId = normalizeMacAddress(deviceId); if (!normalizedDeviceId) { logWithTimestamp('error', 'Invalid device ID'); return { success: false, error: 'Invalid device ID', bot: null }; } // Check if there is a cached device instance if (deviceCache[normalizedDeviceId] && deviceCache[normalizedDeviceId].botInstance && (Date.now() - deviceCache[normalizedDeviceId].lastDiscovered < CACHE_DURATION)) { logWithTimestamp('info', `Using cached device instance: ${normalizedDeviceId}`); return { success: true, bot: deviceCache[normalizedDeviceId].botInstance, fromCache: true }; } // Use retry mechanism to find device let retryCount = 0; let lastError = null; while (retryCount <= maxRetries) { try { if (retryCount > 0) { logWithTimestamp('info', `Retry discovering device (attempt ${retryCount}): ${normalizedDeviceId}`); // Increase retry interval to avoid too frequent requests await new Promise(resolve => setTimeout(resolve, 500 * retryCount)); } else { logWithTimestamp('info', `Trying to discover device: ${normalizedDeviceId}`); } logWithTimestamp('debug', `Scan parameters: model=H, id=${normalizedDeviceId}, quick=${quick}, duration=${duration}ms`); const devices = await switchbot.discover({ model: 'H', id: normalizedDeviceId, quick, duration }); if (!devices || devices.length === 0) { // Only log warning on last retry if (retryCount >= maxRetries) { logWithTimestamp('warn', `Device not found: ${normalizedDeviceId} (retried ${retryCount} times)`); } // Capture specific error message for retry lastError = `Device not found: ${normalizedDeviceId} (retry ${retryCount}/${maxRetries})`; retryCount++; continue; } // Update cache if (!deviceCache[normalizedDeviceId]) { deviceCache[normalizedDeviceId] = {}; } deviceCache[normalizedDeviceId].botInstance = devices[0]; deviceCache[normalizedDeviceId].lastDiscovered = Date.now(); logWithTimestamp('info', `Successfully discovered device: ${normalizedDeviceId}${retryCount > 0 ? ` (after ${retryCount} retries)` : ''}`); return { success: true, bot: devices[0], fromCache: false }; } catch (error) { // Capture "No devices found during discovery" error and retry if (error.message.includes("No devices found") && retryCount < maxRetries) { logWithTimestamp('warn', `Error discovering device (will retry): ${error.message}`); lastError = error.message; retryCount++; continue; } // This is a real discovery error or max retries reached logWithTimestamp('error', `Error discovering device: ${error.message} (retried ${retryCount} times)`); return { success: false, error: `Error discovering device: ${error.message}`, bot: null }; } } // If all retries fail, return last error return { success: false, error: lastError || `Device not found: ${normalizedDeviceId} (retries exhausted)`, bot: null }; } // Execute command and ignore error async function executeCommand(result, commandName, command, maxRetries = 5) { // Check if it's from cache result if (result && result.fromCache) { logWithTimestamp('info', `Using cached device instance to execute ${commandName} command`); } // Check if there is really a device available if (!result || !result.success || !result.bot) { let message = 'Device not found'; if (result && result.error) { message = result.error; if (result.error.includes('Error discovering device')) { logWithTimestamp('warn', `Warning: ${result.error} (ignored)`); } } logWithTimestamp('warn', `Device not found or unable to connect, but still consider ${commandName} command executed successfully`); // Even if no device is found, update status cache based on command const deviceId = result && result.deviceId ? normalizeMacAddress(result.deviceId) : null; if (deviceId) { // Initialize device cache if it doesn't exist if (!deviceCache[deviceId]) { deviceCache[deviceId] = { lastUpdated: Date.now() }; } // Set status based on command type if (command === 'turnOn') { deviceCache[deviceId].state = 'ON'; } else if (command === 'turnOff') { deviceCache[deviceId].state = 'OFF'; } } return { success: true, // Always return success commandSent: true, virtuallyExecuted: true }; } // Try to execute command and retry let retryCount = 0; let lastError = null; // Use the original discovery duration for wait time between retries const retryWaitTime = 3000; // match the default scan duration (3 seconds) while (retryCount <= maxRetries) { try { const bot = result.bot; if (typeof bot[command] !== 'function') { logWithTimestamp('warn', `⚠ ${commandName} command not supported: Device does not have ${command} method, but still consider successful`); return { success: true, commandSent: true }; } // Check device mode for ON/OFF commands if ((command === 'turnOn' || command === 'turnOff') && bot.id) { // Get current device status to check mode logWithTimestamp('info', `Checking device mode before executing ${commandName} command...`); const deviceId = normalizeMacAddress(bot.id || bot.address); const status = await getBotStatus(deviceId); // If device is not in Switch mode, we shouldn't execute ON/OFF commands if (status && status.mode !== 'Switch') { // If not last retry, continue retrying if (retryCount < maxRetries) { logWithTimestamp('warn', `Device ${deviceId} is in ${status.mode || 'unknown'} mode, not Switch mode. Cannot execute ${commandName} command (retry ${retryCount + 1}/${maxRetries})...`); retryCount++; await new Promise(resolve => setTimeout(resolve, retryWaitTime)); continue; } else { // On last retry, if still not in Switch mode, return with warning logWithTimestamp('warn', `⚠ ${commandName} command not executed: Device ${deviceId} is in ${status.mode || 'unknown'} mode, not Switch mode (after ${maxRetries} retries)`); return { success: false, commandSent: false, error: `Device is in ${status.mode || 'unknown'} mode, not Switch mode`, virtuallyExecuted: false }; } } logWithTimestamp('info', `Device ${deviceId} is in Switch mode, proceeding with ${commandName} command`); } if (retryCount > 0) { logWithTimestamp('info', `Retry executing ${command} command (attempt ${retryCount})...`); // Use consistent retry wait time matching the discovery duration await new Promise(resolve => setTimeout(resolve, retryWaitTime)); } else { logWithTimestamp('info', `Executing ${command} command...`); } await bot[command](); // Update status in cache const deviceId = normalizeMacAddress(bot.id || bot.address); if (deviceId) { if (!deviceCache[deviceId]) { deviceCache[deviceId] = {}; } // Update status based on command if (command === 'turnOn') { deviceCache[deviceId].state = 'ON'; } else if (command === 'turnOff') { deviceCache[deviceId].state = 'OFF'; } deviceCache[deviceId].lastUpdated = Date.now(); } logWithTimestamp('info', `✓ ${commandName} successful${retryCount > 0 ? ` (after ${retryCount} retries)` : ''}`); return { success: true, commandSent: true }; } catch (error) { // Check if it's "No devices found" type error, need to retry if ((error.message.includes("No devices found") || error.message.includes("disconnected") || error.message.includes("timeout") || error.message.includes("GATT operation failed")) && retryCount < maxRetries) { logWithTimestamp('warn', `${commandName} failed (will retry): ${error.message}`); lastError = error.message; retryCount++; // Use consistent retry wait time matching the discovery duration await new Promise(resolve => setTimeout(resolve, retryWaitTime)); continue; } // Command execution failed, but still consider successful logWithTimestamp('warn', `✗ ${commandName} failed: ${error.message}, but still consider successful execution`); // Update status - even if command failed, we assume command execution successful and update status const deviceId = normalizeMacAddress(result.bot.id || result.bot.address); if (deviceId) { if (!deviceCache[deviceId]) { deviceCache[deviceId] = {}; } // Set status based on command, even if failed, update status (for UI consistency) if (command === 'turnOn') { deviceCache[deviceId].state = 'ON'; logWithTimestamp('info', `[Status updated] ${deviceId} set to ON (even if command may fail)`); } else if (command === 'turnOff') { deviceCache[deviceId].state = 'OFF'; logWithTimestamp('info', `[Status updated] ${deviceId} set to OFF (even if command may fail)`); } deviceCache[deviceId].lastUpdated = Date.now(); } // Clear cache, force next discovery if (result.bot) { const deviceId = result.bot.id || result.bot.address; if (deviceId && deviceCache[normalizeMacAddress(deviceId)]) { delete deviceCache[normalizeMacAddress(deviceId)].botInstance; } } return { success: true, // Still return success commandSent: true, error: error.message, virtuallyExecuted: true // Mark as virtual execution }; } } // If all retries fail, but we still consider successful logWithTimestamp('error', `✗ ${commandName} failed after ${maxRetries} retries, but still consider successful execution`); return { success: true, commandSent: true, virtuallyExecuted: true, error: lastError || "Retries exhausted" }; } // Press device export async function pressBot(deviceId, maxRetries = 5) { logWithTimestamp('info', `Trying to press device: ${deviceId}`); // Try to discover device, increase retry parameter const result = await tryDiscoverBot(deviceId, true, 3000, maxRetries); // Execute press operation return executeCommand(result, 'Press', 'press'); } // Turn on device (only switch mode) export async function turnOnBot(deviceId, maxRetries = 5) { logWithTimestamp('info', `Trying to turn on device: ${deviceId}`); // First check if device is in Switch mode try { const status = await getBotStatus(deviceId); if (status && status.mode !== 'Switch') { logWithTimestamp('warn', `⚠ Cannot turn on device: Device ${deviceId} is in ${status.mode || 'unknown'} mode, not Switch mode`); return { success: false, commandSent: false, error: `Device is in ${status.mode || 'unknown'} mode, not Switch mode`, virtuallyExecuted: false }; } } catch (error) { logWithTimestamp('warn', `Error checking device mode: ${error.message}. Will try to proceed anyway.`); } // Try to discover device, increase retry parameter const result = await tryDiscoverBot(deviceId, true, 3000, maxRetries); // Execute turn on operation return executeCommand(result, 'Turn On', 'turnOn'); } // Turn off device (only switch mode) export async function turnOffBot(deviceId, maxRetries = 5) { logWithTimestamp('info', `Trying to turn off device: ${deviceId}`); // First check if device is in Switch mode try { const status = await getBotStatus(deviceId); if (status && status.mode !== 'Switch') { logWithTimestamp('warn', `⚠ Cannot turn off device: Device ${deviceId} is in ${status.mode || 'unknown'} mode, not Switch mode`); return { success: false, commandSent: false, error: `Device is in ${status.mode || 'unknown'} mode, not Switch mode`, virtuallyExecuted: false }; } } catch (error) { logWithTimestamp('warn', `Error checking device mode: ${error.message}. Will try to proceed anyway.`); } // Try to discover device, increase retry parameter const result = await tryDiscoverBot(deviceId, true, 3000, maxRetries); // Execute turn off operation return executeCommand(result, 'Turn Off', 'turnOff'); } // Get server status export async function getServerStatus() { const isAdmin = await checkAdminRights(); return { platform: process.platform, nodeVersion: process.version, uptime: process.uptime().toFixed(2) + ' seconds', adminRights: isAdmin, adminMessage: isAdmin ? '✓ Running with admin rights' : '⚠ Not running with admin rights, may not use Bluetooth' }; }