homebridge-switchbot-smartlock
Version:
A Homebridge plugin for controlling SwitchBot Smart Lock via the SwitchBot Cloud API.
464 lines (381 loc) • 20.8 kB
JavaScript
'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 };