node-switchbot
Version:
The node-switchbot is a Node.js module which allows you to control your Switchbot Devices through Bluetooth (BLE) with automatic OpenAPI fallback.
861 lines • 29.9 kB
JavaScript
/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved.
*
* devices/base.ts: SwitchBot v4.0.0 - Base Device Class
*/
import { Buffer } from 'node:buffer';
import { EventEmitter } from 'node:events';
import { APINotAvailableError, BLENotAvailableError } from '../errors.js';
import { DEVICE_COMMANDS } from '../settings.js';
import { CircuitBreaker, CircuitBreakerState, ConnectionTracker, FallbackHandlerManager, Logger, RetryExecutor, } from '../utils/index.js';
// Passive polling interval (24 hours)
export const PASSIVE_POLL_INTERVAL = 60 * 60 * 24 * 1000;
/**
* Base class for all SwitchBot devices
*
* ## BLE-first, API-fallback Logic
*
* This class provides a centralized, robust hybrid connection strategy for all SwitchBot devices:
*
* - **BLE-first, API-fallback**: By default, status and command methods attempt BLE first (if available), then fall back to OpenAPI if BLE fails or is unavailable. This is controlled by `preferredConnection` and `enableFallback`.
* - **Centralized Fallback**: The `getStatusWithFallback()` and `sendCommand()` methods implement this logic. Device subclasses should call these methods and provide normalization/mapping as needed.
* - **Connection Intelligence**: Tracks connection health and performance, automatically preferring the most reliable connection if enabled.
* - **Circuit Breaker & Retry**: Both BLE and API commands are protected by circuit breaker and retry logic to handle transient failures gracefully.
*
* ### Usage in Subclasses
*
* - For status: Call `await this.getStatusWithFallback(normalizeBLE, normalizeAPI)` in your `getStatus()` implementation.
* - For commands: Use `await this.sendCommand(bleCommand, apiCommand, apiParameter)` to automatically select the best connection and handle fallback.
* - For custom logic: You may override or extend these methods, but should preserve the fallback and error-handling patterns for consistency.
*
* ### Example (in a device subclass)
*
* ```typescript
* async getStatus(): Promise<DeviceStatus> {
* return this.getStatusWithFallback(
* bleData => ({ ... }), // normalize BLE data
* apiData => ({ ... }), // normalize API data
* )
* }
*
* async turnOn(): Promise<boolean> {
* const result = await this.sendCommand([0x57, 0x01, 0x01], 'turnOn')
* return result.success
* }
* ```
*
* ### Configuration
*
* - `preferredConnection`: 'ble' | 'api' (default: 'ble')
* - `enableFallback`: boolean (default: true)
* - `enableConnectionIntelligence`: boolean (default: true)
* - `enableCircuitBreaker`: boolean (default: true)
* - `enableRetry`: boolean (default: true)
*
* ### See Also
* - `getStatusWithFallback()`
* - `sendCommand()`
* - `hasBLE()`, `hasAPI()`
* - `setPreferredConnection()`, `setFallbackEnabled()`
*
* This pattern ensures all device classes benefit from robust, testable, and consistent connection logic.
*/
export class SwitchBotDevice extends EventEmitter {
info;
logger;
bleConnection;
apiClient;
enableFallback;
preferredConnection;
// Advanced features
connectionTracker;
circuitBreakerBLE;
circuitBreakerAPI;
fallbackHandlerManager;
retryExecutor;
enableConnectionIntelligence;
enableCircuitBreaker;
enableRetry;
bleOperationQueue = Promise.resolve();
// Passive polling
lastPolledAt;
defineCompatibilityProperties() {
const properties = ['id', 'name', 'deviceType', 'mac', 'activeConnection'];
for (const property of properties) {
Object.defineProperty(this, property, {
enumerable: true,
configurable: true,
get: () => {
const value = this.info[property];
if ((property === 'id' || property === 'mac') && typeof value === 'string' && value.length === 0) {
return undefined;
}
return value;
},
});
}
}
constructor(info, options = {}) {
super();
this.info = info;
this.defineCompatibilityProperties();
this.logger = new Logger(`${info.deviceType}:${info.id}`, options.logLevel);
this.bleConnection = options.bleConnection;
this.apiClient = options.apiClient;
this.enableFallback = options.enableFallback ?? true;
this.preferredConnection = options.preferredConnection ?? 'ble';
// Initialize advanced features
this.enableConnectionIntelligence = options.enableConnectionIntelligence ?? true;
this.enableCircuitBreaker = options.enableCircuitBreaker ?? true;
this.enableRetry = options.enableRetry ?? true;
// Create connection tracker for this device
this.connectionTracker = new ConnectionTracker(info.id, options.logLevel);
// Create circuit breakers for each connection type
this.circuitBreakerBLE = new CircuitBreaker(`${info.deviceType}:${info.id}:BLE`, options.circuitBreakerConfig, options.logLevel);
this.circuitBreakerAPI = new CircuitBreaker(`${info.deviceType}:${info.id}:API`, options.circuitBreakerConfig, options.logLevel);
// Create retry executor
this.retryExecutor = new RetryExecutor(options.retryConfig, options.logLevel);
// Create fallback handler manager
this.fallbackHandlerManager = new FallbackHandlerManager(options.logLevel);
}
/**
* Send multiple commands in sequence (all must succeed)
* Used for Curtain 3, bulbs, strips, and other multi-step devices
*/
async sendCommandSequence(commands) {
try {
for (const command of commands) {
const success = await command();
if (!success) {
this.logger.warn('Command in sequence failed, stopping execution');
return false;
}
// Small delay between commands for device processing
await new Promise(resolve => setTimeout(resolve, 100));
}
return true;
}
catch (error) {
this.logger.error('Command sequence failed', error);
return false;
}
}
/**
* Send multiple commands (returns true if any succeed)
* Used for fallback operations with complex patterns
*/
async sendMultipleCommands(commands) {
let anySucceeded = false;
for (const command of commands) {
try {
const success = await command();
if (success) {
anySucceeded = true;
}
// Small delay between commands
await new Promise(resolve => setTimeout(resolve, 100));
}
catch (error) {
this.logger.debug('Command in multi-command attempt failed', error);
// Continue trying other commands
}
}
return anySucceeded;
}
/**
* Returns true if device should be polled (passive polling interval elapsed)
*/
pollNeeded(interval = PASSIVE_POLL_INTERVAL) {
if (!this.lastPolledAt) {
return true;
}
return Date.now() - this.lastPolledAt > interval;
}
/**
* Poll device status if needed (passive polling)
*/
async pollIfNeeded(interval = PASSIVE_POLL_INTERVAL) {
if (this.pollNeeded(interval)) {
const status = await this.getStatus();
this.lastPolledAt = Date.now();
return status;
}
return undefined;
}
/**
* Get device information
*/
getInfo() {
return { ...this.info };
}
/**
* Get device ID
*/
getId() {
return this.info.id;
}
/**
* Get device ID (property accessor for convenience)
*/
get id() {
return this.info.id.length > 0 ? this.info.id : undefined;
}
/**
* Get device name
*/
getName() {
return this.info.name;
}
/**
* Get device name (property accessor for convenience)
*/
get name() {
return this.info.name;
}
/**
* Get device type
*/
getDeviceType() {
return this.info.deviceType;
}
/**
* Get device type (property accessor for convenience)
*/
get deviceType() {
return this.info.deviceType;
}
/**
* Get MAC address (if available)
*/
getMAC() {
return this.info.mac && this.info.mac.length > 0 ? this.info.mac : undefined;
}
/**
* Get MAC address (property accessor for convenience)
*/
get mac() {
return this.info.mac && this.info.mac.length > 0 ? this.info.mac : undefined;
}
/**
* Get active connection type
*/
getActiveConnection() {
return this.info.activeConnection;
}
/**
* Get active connection type (property accessor for convenience)
*/
get activeConnection() {
return this.info.activeConnection;
}
/**
* Check if BLE is available for this device
*/
hasBLE() {
return this.info.connectionTypes.includes('ble') && !!this.bleConnection && !!this.info.mac;
}
/**
* Check if API is available for this device
*/
hasAPI() {
return this.info.connectionTypes.includes('api') && !!this.apiClient && this.info.cloudServiceEnabled !== false;
}
/**
* Get device status (abstract - implemented by subclasses)
*/
/**
* Get device status with BLE-first/API-fallback logic (centralized)
* Subclasses should call this and map/normalize fields as needed.
*/
async getStatusWithFallback(normalizeBLE, normalizeAPI) {
// Determine connection order
const preferBLE = this.preferredConnection === 'ble';
const tryBLE = () => this.getBLEStatus().then(normalizeBLE ?? (d => d));
const tryAPI = () => this.getAPIStatus().then(normalizeAPI ?? (d => d));
// BLE-first
if (preferBLE && this.hasBLE()) {
try {
return await tryBLE();
}
catch (err) {
this.logger?.warn?.('BLE getStatus failed, falling back to API', err);
}
if (this.enableFallback && this.hasAPI()) {
return await tryAPI();
}
}
// API-first
if (!preferBLE && this.hasAPI()) {
try {
return await tryAPI();
}
catch (err) {
this.logger?.warn?.('API getStatus failed, falling back to BLE', err);
}
if (this.enableFallback && this.hasBLE()) {
return await tryBLE();
}
}
throw new Error('No connection method available for getStatus');
}
/**
* Send a command via BLE with circuit breaker and retry logic
*/
async sendBLECommand(command) {
if (!this.hasBLE()) {
return {
success: false,
connectionType: 'ble',
error: 'BLE not available for this device',
};
}
// Check circuit breaker
if (this.enableCircuitBreaker && !this.circuitBreakerBLE.canExecute()) {
const state = this.circuitBreakerBLE.getState();
if (state === CircuitBreakerState.OPEN) {
return {
success: false,
connectionType: 'ble',
error: 'BLE circuit breaker is OPEN (too many failures)',
};
}
}
const executeCommand = async () => {
this.logger.debug('Sending BLE command', command);
const buffer = Buffer.isBuffer(command) ? command : Buffer.from(command);
const startTime = Date.now();
const mac = this.info.mac ?? `id:${this.info.bleId}`;
let response;
if (this.info.encryptionKey && this.info.encryptionIV && this.bleConnection?.setEncryption) {
this.bleConnection.setEncryption(mac, this.info.encryptionKey, this.info.encryptionIV, this.info.encryptionMode ?? 'auto');
}
if (this.bleConnection?.sendCommand) {
response = await this.bleConnection.sendCommand(mac, buffer, {
expectResponse: true,
validateResponse: true,
responseTimeoutMs: 1200,
});
}
else {
await this.bleConnection.write(mac, buffer);
}
const latencyMs = Date.now() - startTime;
// Record success
if (this.enableConnectionIntelligence) {
this.connectionTracker.recordSuccess('ble', latencyMs);
}
if (this.enableCircuitBreaker) {
this.circuitBreakerBLE.recordSuccess();
}
this.info.activeConnection = 'ble';
this.emit('command', { type: 'ble', success: true });
return {
success: true,
connectionType: 'ble',
data: response,
};
};
try {
return await this.runWithBLELock(async () => {
if (this.enableRetry) {
this.circuitBreakerBLE.markHalfOpenAttempt();
return await this.retryExecutor.executeOrThrow(executeCommand, `BLE command for ${this.info.id}`);
}
else {
if (this.enableCircuitBreaker) {
this.circuitBreakerBLE.markHalfOpenAttempt();
}
return await executeCommand();
}
});
}
catch (error) {
// Record failure
if (this.enableConnectionIntelligence) {
this.connectionTracker.recordFailure('ble');
}
if (this.enableCircuitBreaker) {
this.circuitBreakerBLE.recordFailure();
}
this.logger.error('BLE command failed', error);
this.emit('error', { type: 'ble', error });
return {
success: false,
connectionType: 'ble',
error: error.message,
};
}
}
/**
* Send a command via OpenAPI with circuit breaker and retry logic
*/
async sendAPICommand(command, parameter) {
if (!this.hasAPI()) {
return {
success: false,
connectionType: 'api',
error: 'API not available for this device',
};
}
// Check circuit breaker
if (this.enableCircuitBreaker && !this.circuitBreakerAPI.canExecute()) {
const state = this.circuitBreakerAPI.getState();
if (state === CircuitBreakerState.OPEN) {
return {
success: false,
connectionType: 'api',
error: 'API circuit breaker is OPEN (too many failures)',
};
}
}
const executeCommand = async () => {
this.logger.debug('Sending API command', { command, parameter });
const startTime = Date.now();
const response = await this.apiClient.sendCommand(this.info.id, command, parameter);
const latencyMs = Date.now() - startTime;
// Record success
if (this.enableConnectionIntelligence) {
this.connectionTracker.recordSuccess('api', latencyMs);
}
if (this.enableCircuitBreaker) {
this.circuitBreakerAPI.recordSuccess();
}
this.info.activeConnection = 'api';
this.emit('command', { type: 'api', success: true });
return {
success: true,
connectionType: 'api',
data: response,
};
};
try {
if (this.enableRetry) {
this.circuitBreakerAPI.markHalfOpenAttempt();
return await this.retryExecutor.executeOrThrow(executeCommand, `API command for ${this.info.id}`);
}
else {
if (this.enableCircuitBreaker) {
this.circuitBreakerAPI.markHalfOpenAttempt();
}
return await executeCommand();
}
}
catch (error) {
// Record failure
if (this.enableConnectionIntelligence) {
this.connectionTracker.recordFailure('api');
}
if (this.enableCircuitBreaker) {
this.circuitBreakerAPI.recordFailure();
}
this.logger.error('API command failed', error);
this.emit('error', { type: 'api', error });
return {
success: false,
connectionType: 'api',
error: error.message,
};
}
}
/**
* Get best connection type based on intelligence tracking
*/
getBestConnection() {
if (!this.enableConnectionIntelligence) {
return this.preferredConnection;
}
const availableTypes = [];
if (this.hasBLE()) {
availableTypes.push('ble');
}
if (this.hasAPI()) {
availableTypes.push('api');
}
if (availableTypes.length === 0) {
return this.preferredConnection;
}
// Get best connection based on statistics
const best = this.connectionTracker.getBestConnection(availableTypes);
if (best) {
return best;
}
return availableTypes.length > 0 ? availableTypes[0] : this.preferredConnection;
}
/**
* Send a command with automatic BLE/API fallback, circuit breaker, and retry logic
*/
async sendCommand(bleCommand, apiCommand, apiParameter) {
// Determine connection strategy
let primaryConnection = this.preferredConnection;
let secondaryConnection;
if (this.enableConnectionIntelligence) {
primaryConnection = this.getBestConnection();
if (this.preferredConnection === 'ble' && primaryConnection === 'api' && this.hasBLE()) {
secondaryConnection = 'ble';
}
else if (this.preferredConnection === 'api' && primaryConnection === 'ble' && this.hasAPI()) {
secondaryConnection = 'api';
}
}
else {
// Fallback based on preferred connection
if (this.preferredConnection === 'ble' && this.hasBLE()) {
primaryConnection = 'ble';
if (this.enableFallback && this.hasAPI()) {
secondaryConnection = 'api';
}
}
else if (this.preferredConnection === 'api' && this.hasAPI()) {
primaryConnection = 'api';
if (this.enableFallback && this.hasBLE()) {
secondaryConnection = 'ble';
}
}
else if (this.hasBLE()) {
primaryConnection = 'ble';
if (this.enableFallback && this.hasAPI()) {
secondaryConnection = 'api';
}
}
else {
primaryConnection = 'api';
secondaryConnection = undefined;
}
}
// Try primary connection
let result;
let fallbackUsed = false;
if (primaryConnection === 'ble') {
result = await this.sendBLECommand(bleCommand);
}
else {
result = await this.sendAPICommand(apiCommand, apiParameter);
}
// Try fallback if primary failed and fallback is enabled
if (!result.success && this.enableFallback && secondaryConnection) {
fallbackUsed = true;
this.logger.warn(`${primaryConnection} failed, attempting fallback to ${secondaryConnection}`);
// Emit fallback event
const fallbackEvent = {
deviceId: this.info.id,
primaryConnection,
fallbackConnection: secondaryConnection,
reason: result.error || 'Connection failed',
timestamp: new Date(),
totalTimeMs: 0,
};
await this.fallbackHandlerManager.emit(fallbackEvent);
if (secondaryConnection === 'ble') {
result = await this.sendBLECommand(bleCommand);
}
else {
result = await this.sendAPICommand(apiCommand, apiParameter);
}
}
if (fallbackUsed) {
result.usedFallback = true;
}
return result;
}
/**
* Get device status via BLE
*/
async getBLEStatus() {
if (!this.hasBLE()) {
throw new BLENotAvailableError('BLE not available for this device');
}
try {
this.logger.debug('Reading BLE status');
const startTime = Date.now();
const data = await this.bleConnection.read(this.info.mac ?? `id:${this.info.bleId}`);
const latencyMs = Date.now() - startTime;
if (this.enableConnectionIntelligence) {
this.connectionTracker.recordSuccess('ble', latencyMs);
}
if (this.enableCircuitBreaker) {
this.circuitBreakerBLE.recordSuccess();
}
this.info.activeConnection = 'ble';
return this.normalizeBLEStatusData(data);
}
catch (error) {
if (this.enableConnectionIntelligence) {
this.connectionTracker.recordFailure('ble');
}
if (this.enableCircuitBreaker) {
this.circuitBreakerBLE.recordFailure();
}
this.logger.error('Failed to read BLE status', error);
throw error;
}
}
normalizeBLEStatusData(data) {
if (data && typeof data === 'object' && !Buffer.isBuffer(data)) {
return data;
}
return (this.info.bleServiceData ?? {});
}
async runWithBLELock(fn) {
const previous = this.bleOperationQueue;
let release = () => { };
this.bleOperationQueue = new Promise((resolve) => {
release = resolve;
});
await previous;
try {
return await fn();
}
finally {
release();
}
}
/**
* Get device status via OpenAPI
*/
async getAPIStatus() {
if (!this.hasAPI()) {
throw new APINotAvailableError('API not available for this device');
}
try {
this.logger.debug('Reading API status');
const startTime = Date.now();
const data = await this.apiClient.getStatus(this.info.id);
const latencyMs = Date.now() - startTime;
if (this.enableConnectionIntelligence) {
this.connectionTracker.recordSuccess('api', latencyMs);
}
if (this.enableCircuitBreaker) {
this.circuitBreakerAPI.recordSuccess();
}
this.info.activeConnection = 'api';
return data;
}
catch (error) {
if (this.enableConnectionIntelligence) {
this.connectionTracker.recordFailure('api');
}
if (this.enableCircuitBreaker) {
this.circuitBreakerAPI.recordFailure();
}
this.logger.error('Failed to read API status', error);
throw error;
}
}
/**
* Get basic device info (universal settings retrieval)
* Returns: battery, firmware, device-specific settings, etc.
* Command: 0x57 0x02 (BLE), 'getBasicInfo' (API)
*
* Example usage:
* const info = await device.getBasicInfo();
* console.log(info);
*
* Returns a CommandResult object with device info fields.
*/
async getBasicInfo() {
// Prefer BLE if available
if (this.hasBLE()) {
return this.sendBLECommand(DEVICE_COMMANDS.RELAY.GET_BASIC_INFO);
}
// Fallback to API if available
if (this.hasAPI()) {
return this.sendAPICommand('getBasicInfo');
}
throw new Error('No available connection for getBasicInfo');
}
/**
* Universal mode setting command
* BLE: 0x57 0x03 [modeByte]
* API: 'setMode' (if available)
* @param mode - Mode value (number or string, per-device enum recommended)
*
* Example usage:
* await device.setMode('auto')
* await device.setMode(1)
*
* Returns a CommandResult object indicating success and mode info.
*/
async setMode(mode) {
// TODO: Extend with per-device mode enums/types as needed
if (this.hasBLE()) {
// BLE expects [0x57, 0x03, modeByte]
const modeByte = typeof mode === 'number' ? mode : Number.parseInt(mode, 10);
return this.sendBLECommand([0x57, 0x03, modeByte]);
}
if (this.hasAPI()) {
// API expects 'setMode' command, parameter may be device-specific
return this.sendAPICommand('setMode', { mode });
}
throw new Error('No available connection for setMode');
}
/**
* Update device information
*/
updateInfo(newInfo) {
this.info = { ...this.info, ...newInfo };
this.emit('info-updated', this.info);
}
/**
* Set preferred connection type
*/
setPreferredConnection(type) {
this.preferredConnection = type;
this.logger.info(`Preferred connection set to ${type}`);
}
/**
* Enable or disable fallback
*/
setFallbackEnabled(enabled) {
this.enableFallback = enabled;
this.logger.info(`Fallback ${enabled ? 'enabled' : 'disabled'}`);
}
/**
* Enable or disable connection intelligence
*/
setConnectionIntelligenceEnabled(enabled) {
this.enableConnectionIntelligence = enabled;
this.logger.info(`Connection intelligence ${enabled ? 'enabled' : 'disabled'}`);
}
/**
* Enable or disable circuit breaker
*/
setCircuitBreakerEnabled(enabled) {
this.enableCircuitBreaker = enabled;
this.logger.info(`Circuit breaker ${enabled ? 'enabled' : 'disabled'}`);
}
/**
* Enable or disable retry logic
*/
setRetryEnabled(enabled) {
this.enableRetry = enabled;
this.logger.info(`Retry logic ${enabled ? 'enabled' : 'disabled'}`);
}
/**
* Get connection tracker for this device
*/
getConnectionTracker() {
return this.connectionTracker;
}
/**
* Get circuit breaker for BLE
*/
getCircuitBreakerBLE() {
return this.circuitBreakerBLE;
}
/**
* Get circuit breaker for API
*/
getCircuitBreakerAPI() {
return this.circuitBreakerAPI;
}
/**
* Register a custom fallback handler
*/
registerFallbackHandler(handler, options) {
return this.fallbackHandlerManager.register(handler, options);
}
/**
* Unregister a fallback handler
*/
unregisterFallbackHandler(id) {
return this.fallbackHandlerManager.unregister(id);
}
/**
* Get fallback handler manager
*/
getFallbackHandlerManager() {
return this.fallbackHandlerManager;
}
}
/**
* Device Manager for managing multiple devices
*/
export class DeviceManager extends EventEmitter {
devices = new Map();
logger;
constructor(logLevel) {
super();
this.logger = new Logger('DeviceManager', logLevel);
}
/**
* Add a device to the manager
*/
add(device) {
const id = device.getId();
if (this.devices.has(id)) {
this.logger.warn(`Device ${id} already exists, replacing`);
}
this.devices.set(id, device);
this.emit('device-added', device);
this.logger.info(`Added device: ${device.getName()} (${id})`);
}
/**
* Remove a device from the manager
*/
remove(deviceId) {
const device = this.devices.get(deviceId);
if (device) {
this.devices.delete(deviceId);
this.emit('device-removed', device);
this.logger.info(`Removed device: ${deviceId}`);
return true;
}
return false;
}
/**
* Get a device by ID
*/
get(deviceId) {
return this.devices.get(deviceId);
}
/**
* Get all devices
*/
list() {
return [...this.devices.values()];
}
/**
* Get devices filtered by type
*/
getByType(deviceType) {
return this.list().filter(device => device.getDeviceType() === deviceType);
}
/**
* Get device by MAC address
*/
getByMAC(mac) {
return this.list().find(device => device.getMAC() === mac);
}
/**
* Check if device exists
*/
has(deviceId) {
return this.devices.has(deviceId);
}
/**
* Get device count
*/
count() {
return this.devices.size;
}
/**
* Clear all devices
*/
clear() {
this.devices.clear();
this.emit('devices-cleared');
this.logger.info('All devices cleared');
}
/**
* Get all device IDs
*/
getIds() {
return [...this.devices.keys()];
}
/**
* Get devices as an object keyed by ID
*/
toObject() {
return Object.fromEntries(this.devices.entries());
}
}
//# sourceMappingURL=base.js.map