UNPKG

homebridge-switchbot-smartlock

Version:

A Homebridge plugin for controlling SwitchBot Smart Lock via the SwitchBot Cloud API.

464 lines (381 loc) 20.8 kB
'use strict'; const axios = require('axios'); const crypto = require('crypto'); const { v4: uuidv4 } = require('uuid'); class SwitchBotSmartLockAccessory { constructor(platform, accessory) { this.platform = platform; this.accessory = accessory; // Generate unique instance ID for debugging this.instanceId = Math.random().toString(36).substr(2, 9); this.platform.log.info(`Creating SwitchBot accessory instance ${this.instanceId} for device ${accessory.context.config.deviceId}`); // Global tracking to detect multiple instances if (!global.switchbotInstances) { global.switchbotInstances = new Map(); } const deviceId = accessory.context.config.deviceId; if (global.switchbotInstances.has(deviceId)) { const existingInstance = global.switchbotInstances.get(deviceId); this.platform.log.warn(`WARNING: Multiple instances detected for device ${deviceId}! Existing: ${existingInstance}, New: ${this.instanceId}`); this.platform.log.warn(`This may cause duplicate API requests and rate limiting. Consider checking your HomeKit configuration.`); } global.switchbotInstances.set(deviceId, this.instanceId); const { Service, Characteristic } = platform.api.hap; // Get the accessory name from config, with fallback const accessoryName = this.accessory.context.config.accessoryName || 'Smart Lock'; // Get or create the lock service this.service = accessory.getService(Service.LockMechanism) || accessory.addService(Service.LockMechanism, accessoryName); // Update service name if it has changed if (this.service.displayName !== accessoryName) { this.platform.log.info(`Updating service name to: ${accessoryName}`); this.service.displayName = accessoryName; this.service.setCharacteristic(this.platform.api.hap.Characteristic.Name, accessoryName); } // CHANGED: Configure the lock current state characteristic with forced refresh // This ensures Home app requests always get fresh data this.service.getCharacteristic(Characteristic.LockCurrentState) .onGet(() => this.getLockState(true)); // Force fresh API call on HomeKit requests // Configure the lock target state characteristic this.service.getCharacteristic(Characteristic.LockTargetState) .onSet(this.setLockState.bind(this)) .onGet(this.getTargetLockState.bind(this)); // Initialize target state this.targetState = Characteristic.LockTargetState.UNSECURED; // Configure polling interval from config (with validation and defaults) const configPollingInterval = this.accessory.context.config.pollingInterval; let pollingIntervalSeconds = 10; // Default to 10 seconds if (configPollingInterval !== undefined) { // Validate the configured interval (5 seconds minimum, 5 minutes maximum) if (configPollingInterval >= 5 && configPollingInterval <= 300) { pollingIntervalSeconds = configPollingInterval; } else { this.platform.log.warn(`Invalid polling interval ${configPollingInterval}s. Must be between 5-300 seconds. Using default: ${pollingIntervalSeconds}s`); } } this.pollingIntervalMs = pollingIntervalSeconds * 1000; this.platform.log.info(`Configured polling interval: ${pollingIntervalSeconds} seconds`); // Track last poll result and timing this.lastPollResult = null; this.lastPollTime = 0; this.pollCooldown = this.pollingIntervalMs; // Use same interval for cooldown // Request deduplication this.pendingRequest = null; // Track in-flight request // Aggressive polling during transitions this.isTransitioning = false; this.aggressivePollingInterval = null; this.aggressivePollingMs = 3000; // Poll every 3 seconds during transitions this.aggressivePollingDurationMs = 30000; // Continue aggressive polling for 30 seconds minimum this.aggressivePollingStartTime = null; this.expectedFinalState = null; // Track what state we're expecting after command this.platform.log.info(`Configured aggressive polling: ${this.aggressivePollingMs / 1000}s interval, ${this.aggressivePollingDurationMs / 1000}s minimum duration`); // Start background polling this.startBackgroundPolling(); // Do an initial state check this.getLockState(true).catch(err => { this.platform.log.debug('Initial state check failed:', err.message); }); } // CHANGED: Added forceRefresh parameter to bypass cache when needed async getLockState(forceRefresh = false) { // If not forcing refresh, check if we have a recent poll result (within cooldown period) if (!forceRefresh) { const now = Date.now(); if (this.lastPollResult && (now - this.lastPollTime) < this.pollCooldown) { this.platform.log.debug(`Returning recent poll result (${Math.round((now - this.lastPollTime) / 1000)}s ago)`); return this.lastPollResult; } } else { this.platform.log.debug('Force refresh requested - bypassing cache'); } // Check if there's already a request in flight if (this.pendingRequest) { this.platform.log.debug('Request already in flight, waiting for result'); return await this.pendingRequest; } // Need to poll - make API call this.platform.log.debug('Making fresh API call for lock state'); const result = await this.updateLockStateFromAPI(); return result; } startBackgroundPolling() { // Clear any existing interval first if (this.backgroundInterval) { this.platform.log.debug(`[${this.instanceId}] Clearing existing background interval`); clearInterval(this.backgroundInterval); this.backgroundInterval = null; } // Poll at the configured interval this.platform.log.info(`[${this.instanceId}] Starting background polling with ${this.pollingIntervalMs / 1000}s interval`); this.backgroundInterval = setInterval(async () => { try { this.platform.log.debug(`[${this.instanceId}] Background polling - checking lock state`); await this.updateLockStateFromAPI(); } catch (err) { this.platform.log.debug(`[${this.instanceId}] Background polling failed:`, err.message); } }, this.pollingIntervalMs); // Clean up on accessory removal this.accessory.on('unpublish', () => { this.platform.log.info(`[${this.instanceId}] Accessory unpublished, cleaning up background polling`); if (this.backgroundInterval) { clearInterval(this.backgroundInterval); this.backgroundInterval = null; } }); } // Start aggressive polling during lock transitions startAggressivePolling(expectedFinalState = null) { if (this.aggressivePollingInterval) { return; // Already running } this.isTransitioning = true; this.aggressivePollingStartTime = Date.now(); this.expectedFinalState = expectedFinalState; const durationText = expectedFinalState ? `until reaching expected state (${expectedFinalState}) or ${this.aggressivePollingDurationMs / 1000}s max` : `for ${this.aggressivePollingDurationMs / 1000}s or until transition complete`; this.platform.log.info(`[${this.instanceId}] Starting aggressive polling (${this.aggressivePollingMs / 1000}s interval) ${durationText}`); this.aggressivePollingInterval = setInterval(async () => { try { this.platform.log.debug(`[${this.instanceId}] Aggressive polling - checking lock state`); await this.updateLockStateFromAPI(); } catch (err) { this.platform.log.debug(`[${this.instanceId}] Aggressive polling failed:`, err.message); } }, this.aggressivePollingMs); } // Stop aggressive polling when transition is complete stopAggressivePolling() { if (this.aggressivePollingInterval) { this.platform.log.info(`[${this.instanceId}] Stopping aggressive polling (transition complete)`); clearInterval(this.aggressivePollingInterval); this.aggressivePollingInterval = null; this.isTransitioning = false; this.aggressivePollingStartTime = null; this.expectedFinalState = null; } } resetBackgroundTimer() { this.platform.log.debug(`[${this.instanceId}] Resetting background polling timer`); this.startBackgroundPolling(); } // Only clear cached state if cooldown period has elapsed clearCachedState() { const now = Date.now(); const timeSinceLastPoll = now - this.lastPollTime; // Only clear if enough time has passed, otherwise keep the cached result if (timeSinceLastPoll >= this.pollCooldown) { this.platform.log.debug('Clearing cached state (cooldown period elapsed)'); this.lastPollResult = null; this.lastPollTime = 0; } else { this.platform.log.debug(`Keeping cached state (only ${Math.round(timeSinceLastPoll / 1000)}s since last poll, need ${this.pollCooldown / 1000}s)`); } } async apiRequest(method, path, body = null) { const { token, secret, deviceId } = this.accessory.context.config; if (!token || !secret || !deviceId) { throw new Error('Missing required configuration: token, secret, or deviceId'); } const t = Date.now().toString(); const nonce = uuidv4(); const dataToSign = token + t + nonce; const sign = crypto.createHmac('sha256', secret).update(dataToSign).digest('base64'); const headers = { Authorization: token, sign, nonce, t, 'Content-Type': 'application/json', }; const url = `https://api.switch-bot.com/v1.1${path}`; const options = { method, url, headers, data: body, timeout: 10000, // 10 second timeout }; try { this.platform.log.debug(`[${this.instanceId}] Making ${method} request to ${url}`); const response = await axios(options); if (response.data.statusCode !== 100) { throw new Error(`API error: ${response.data.message || 'Unknown error'}`); } return response.data; } catch (err) { this.platform.log.error(`[${this.instanceId}] API request failed:`, err.message); if (err.response) { this.platform.log.error(`[${this.instanceId}] Response status:`, err.response.status); this.platform.log.error(`[${this.instanceId}] Response data:`, err.response.data); } throw err; } } async updateLockStateFromAPI() { // Prevent concurrent requests using shared promise if (this.pendingRequest) { this.platform.log.debug('API request already in progress, reusing existing request'); return await this.pendingRequest; } // Create and store the promise this.pendingRequest = this._doUpdateLockStateFromAPI(); try { const result = await this.pendingRequest; return result; } finally { // Clear the pending request when done (success or failure) this.pendingRequest = null; } } async _doUpdateLockStateFromAPI() { try { const { deviceId } = this.accessory.context.config; const data = await this.apiRequest('GET', `/devices/${deviceId}/status`); if (!data.body) { throw new Error('Invalid response: missing body'); } // Log the full response for debugging this.platform.log.debug(`[${this.instanceId}] API Response:`, JSON.stringify(data)); const lockState = data.body.lockState; const { Characteristic } = this.platform.api.hap; // Convert API state to HomeKit state let currentState; let shouldUpdateTarget = false; let newTargetState; let isTransitionState = false; switch (lockState) { case 'locked': // Final locked state currentState = Characteristic.LockCurrentState.SECURED; newTargetState = Characteristic.LockTargetState.SECURED; shouldUpdateTarget = true; break; case 'unlocked': // Final unlocked state currentState = Characteristic.LockCurrentState.UNSECURED; newTargetState = Characteristic.LockTargetState.UNSECURED; shouldUpdateTarget = true; break; case 'locking': // Lock is in progress - show as unsecured (transitioning from unlocked) currentState = Characteristic.LockCurrentState.UNSECURED; isTransitionState = true; this.platform.log.debug(`[${this.instanceId}] Lock is transitioning to locked state`); break; case 'unlocking': // Unlock is in progress - show as secured (transitioning from locked) currentState = Characteristic.LockCurrentState.SECURED; isTransitionState = true; this.platform.log.debug(`[${this.instanceId}] Lock is transitioning to unlocked state`); break; default: this.platform.log.warn(`[${this.instanceId}] Unknown lock state received: "${lockState}"`); currentState = Characteristic.LockCurrentState.UNKNOWN; shouldUpdateTarget = false; } // NEW: Handle transition state polling with time-based logic if (isTransitionState && !this.isTransitioning) { // Start aggressive polling when we detect a transition (natural state detection) this.startAggressivePolling(); } else if (!isTransitionState && this.isTransitioning) { // We're in a final state - check if we should stop aggressive polling const now = Date.now(); const timeInAggressivePolling = now - this.aggressivePollingStartTime; const minDurationPassed = timeInAggressivePolling >= this.aggressivePollingDurationMs; const isExpectedState = this.expectedFinalState ? (currentState === this.expectedFinalState) : true; // Stop aggressive polling if: // 1. Minimum duration has passed AND we got expected state, OR // 2. We've been polling for much longer (safety timeout - 2x duration) const safetyTimeoutReached = timeInAggressivePolling >= (this.aggressivePollingDurationMs * 2); if ((minDurationPassed && isExpectedState) || safetyTimeoutReached) { const reason = safetyTimeoutReached ? 'safety timeout' : (this.expectedFinalState ? `reached expected state after ${Math.round(timeInAggressivePolling / 1000)}s` : `minimum duration completed after ${Math.round(timeInAggressivePolling / 1000)}s`); this.platform.log.info(`[${this.instanceId}] Stopping aggressive polling: ${reason}`); this.stopAggressivePolling(); } else { const waitingFor = this.expectedFinalState && !isExpectedState ? `expected state (${this.expectedFinalState})` : 'minimum duration'; this.platform.log.debug(`[${this.instanceId}] Final state reached but continuing aggressive polling - waiting for ${waitingFor} (${Math.round(timeInAggressivePolling / 1000)}s elapsed)`); } } // Store poll result and timestamp this.lastPollResult = currentState; this.lastPollTime = Date.now(); // Update target state for final states only if (shouldUpdateTarget && this.targetState !== newTargetState) { this.platform.log.info(`[${this.instanceId}] Lock target state synced: ${this.targetState} → ${newTargetState} (API: "${lockState}")`); this.targetState = newTargetState; this.service.updateCharacteristic(Characteristic.LockTargetState, newTargetState); } // Always update current state this.service.updateCharacteristic(Characteristic.LockCurrentState, currentState); this.platform.log.info(`[${this.instanceId}] Lock state: "${lockState}" → Current: ${currentState}, Target: ${this.targetState}${isTransitionState ? ' (TRANSITIONING)' : ''}`); return currentState; } catch (err) { this.platform.log.error(`[${this.instanceId}] Failed to get lock state:`, err.message); const { Characteristic } = this.platform.api.hap; const unknownState = Characteristic.LockCurrentState.UNKNOWN; // Stop aggressive polling on error to avoid repeated failed requests if (this.isTransitioning) { this.stopAggressivePolling(); } // Store unknown state as last result this.lastPollResult = unknownState; this.lastPollTime = Date.now(); return unknownState; } } async getTargetLockState() { return this.targetState; } async setLockState(value) { try { const { deviceId } = this.accessory.context.config; const { Characteristic } = this.platform.api.hap; // Only clear cached state if cooldown period has elapsed this.clearCachedState(); this.targetState = value; const command = value === Characteristic.LockTargetState.SECURED ? 'lock' : 'unlock'; this.platform.log.info(`[${this.instanceId}] Setting lock to: ${command}`); await this.apiRequest('POST', `/devices/${deviceId}/commands`, { command, parameter: 'default', commandType: 'command', }); // NEW: Start aggressive polling immediately after sending command // This ensures we catch the transition state quickly and continue until we get the expected final state const expectedFinalState = value === Characteristic.LockTargetState.SECURED ? Characteristic.LockCurrentState.SECURED : Characteristic.LockCurrentState.UNSECURED; this.platform.log.debug(`[${this.instanceId}] Command sent, starting aggressive polling for transition detection (expecting final state: ${expectedFinalState})`); this.startAggressivePolling(expectedFinalState); // Reset the background timer so next check is at the configured interval from now this.resetBackgroundTimer(); } catch (err) { this.platform.log.error(`[${this.instanceId}] Failed to set lock state:`, err.message); throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); } } destroy() { this.platform.log.info(`[${this.instanceId}] Destroying accessory, cleaning up all polling`); // Clean up global tracking const deviceId = this.accessory.context.config.deviceId; if (global.switchbotInstances && global.switchbotInstances.get(deviceId) === this.instanceId) { global.switchbotInstances.delete(deviceId); } // Clean up background polling if (this.backgroundInterval) { clearInterval(this.backgroundInterval); this.backgroundInterval = null; } // NEW: Clean up aggressive polling this.stopAggressivePolling(); // Clear any pending request this.pendingRequest = null; } } module.exports = { SwitchBotSmartLockAccessory };