UNPKG

node-switchbot

Version:

The node-switchbot is a Node.js module which allows you to control your Switchbot Devices through Bluetooth (BLE).

1,201 lines 139 kB
import { Buffer } from 'node:buffer'; import * as Crypto from 'node:crypto'; import { EventEmitter } from 'node:events'; import { CHAR_UUID_DEVICE, CHAR_UUID_NOTIFY, CHAR_UUID_WRITE, READ_TIMEOUT_MSEC, SERV_UUID_PRIMARY, WoSmartLockCommands, WoSmartLockProCommands, WRITE_TIMEOUT_MSEC } from './settings.js'; /** * Command constants for various SwitchBot devices. * Using readonly arrays to ensure immutability and better type safety. */ const DEVICE_COMMANDS = { BLIND_TILT: { OPEN: [0x57, 0x0F, 0x45, 0x01, 0x05, 0xFF, 0x32], CLOSE_UP: [0x57, 0x0F, 0x45, 0x01, 0x05, 0xFF, 0x64], CLOSE_DOWN: [0x57, 0x0F, 0x45, 0x01, 0x05, 0xFF, 0x00], PAUSE: [0x57, 0x0F, 0x45, 0x01, 0x00, 0xFF], }, BULB: { BASE: [0x57, 0x0F, 0x47, 0x01], READ_STATE: [0x57, 0x0F, 0x48, 0x01], TURN_ON: [0x01, 0x01], TURN_OFF: [0x01, 0x02], SET_BRIGHTNESS: [0x02, 0x14], SET_COLOR_TEMP: [0x02, 0x17], SET_RGB: [0x02, 0x12], }, HUMIDIFIER: { HEADER: '5701', TURN_ON: '570101', TURN_OFF: '570102', INCREASE: '570103', DECREASE: '570104', SET_AUTO_MODE: '570105', SET_MANUAL_MODE: '570106', }, AIR_PURIFIER: { TURN_ON: [0x57, 0x01, 0x01], TURN_OFF: [0x57, 0x01, 0x02], SET_MODE: [0x57, 0x02], SET_SPEED: [0x57, 0x03], }, // Common commands used across multiple devices COMMON: { POWER_ON: [0x57, 0x01, 0x01], POWER_OFF: [0x57, 0x01, 0x02], }, }; /** * Air quality level constants for air purifier devices. */ const AIR_QUALITY_LEVELS = { EXCELLENT: 'excellent', GOOD: 'good', FAIR: 'fair', POOR: 'poor', }; /** * Air purifier mode constants. */ const AIR_PURIFIER_MODES = { MANUAL: 'manual', AUTO: 'auto', SLEEP: 'sleep', LEVEL_1: 'level_1', LEVEL_2: 'level_2', LEVEL_3: 'level_3', }; // Legacy constants for backward compatibility const BLIND_TILT_COMMANDS = DEVICE_COMMANDS.BLIND_TILT; const BULB_COMMANDS = DEVICE_COMMANDS.BULB; const HUMIDIFIER_COMMAND_HEADER = DEVICE_COMMANDS.HUMIDIFIER.HEADER; const TURN_ON_KEY = DEVICE_COMMANDS.HUMIDIFIER.TURN_ON; const TURN_OFF_KEY = DEVICE_COMMANDS.HUMIDIFIER.TURN_OFF; const INCREASE_KEY = DEVICE_COMMANDS.HUMIDIFIER.INCREASE; const DECREASE_KEY = DEVICE_COMMANDS.HUMIDIFIER.DECREASE; const SET_AUTO_MODE_KEY = DEVICE_COMMANDS.HUMIDIFIER.SET_AUTO_MODE; const SET_MANUAL_MODE_KEY = DEVICE_COMMANDS.HUMIDIFIER.SET_MANUAL_MODE; export var SwitchBotModel; (function (SwitchBotModel) { SwitchBotModel["HubMini"] = "W0202200"; SwitchBotModel["HubPlus"] = "SwitchBot Hub S1"; SwitchBotModel["Hub2"] = "W3202100"; SwitchBotModel["Hub3"] = "W3302100"; SwitchBotModel["Bot"] = "SwitchBot S1"; SwitchBotModel["Curtain"] = "W0701600"; SwitchBotModel["Curtain3"] = "W2400000"; SwitchBotModel["Humidifier"] = "W0801800"; SwitchBotModel["Humidifier2"] = "WXXXXXXX"; SwitchBotModel["Plug"] = "SP11"; SwitchBotModel["Meter"] = "SwitchBot MeterTH S1"; SwitchBotModel["MeterPlusJP"] = "W2201500"; SwitchBotModel["MeterPlusUS"] = "W2301500"; SwitchBotModel["MeterPro"] = "W4900000"; SwitchBotModel["MeterProCO2"] = "W4900010"; SwitchBotModel["OutdoorMeter"] = "W3400010"; SwitchBotModel["MotionSensor"] = "W1101500"; SwitchBotModel["ContactSensor"] = "W1201500"; SwitchBotModel["ColorBulb"] = "W1401400"; SwitchBotModel["StripLight"] = "W1701100"; SwitchBotModel["PlugMiniUS"] = "W1901400/W1901401"; SwitchBotModel["PlugMiniJP"] = "W2001400/W2001401"; SwitchBotModel["Lock"] = "W1601700"; SwitchBotModel["LockPro"] = "W3500000"; SwitchBotModel["Keypad"] = "W2500010"; SwitchBotModel["KeypadTouch"] = "W2500020"; SwitchBotModel["K10"] = "K10+"; SwitchBotModel["K10Pro"] = "K10+ Pro"; SwitchBotModel["WoSweeper"] = "WoSweeper"; SwitchBotModel["WoSweeperMini"] = "WoSweeperMini"; SwitchBotModel["RobotVacuumCleanerS1"] = "W3011000"; SwitchBotModel["RobotVacuumCleanerS1Plus"] = "W3011010"; SwitchBotModel["RobotVacuumCleanerS10"] = "W3211800"; SwitchBotModel["Remote"] = "Remote"; SwitchBotModel["UniversalRemote"] = "UniversalRemote"; SwitchBotModel["CeilingLight"] = "W2612230/W2612240"; SwitchBotModel["CeilingLightPro"] = "W2612210/W2612220"; SwitchBotModel["IndoorCam"] = "W1301200"; SwitchBotModel["PanTiltCam"] = "W1801200"; SwitchBotModel["PanTiltCam2K"] = "W3101100"; SwitchBotModel["BlindTilt"] = "W2701600"; SwitchBotModel["BatteryCirculatorFan"] = "W3800510"; SwitchBotModel["CirculatorFan"] = "W3800511"; SwitchBotModel["WaterDetector"] = "W4402000"; SwitchBotModel["RelaySwitch1"] = "W5502300"; SwitchBotModel["RelaySwitch1PM"] = "W5502310"; SwitchBotModel["Unknown"] = "Unknown"; SwitchBotModel["AirPurifier"] = "W5302300"; SwitchBotModel["AirPurifierTable"] = "W5302310"; })(SwitchBotModel || (SwitchBotModel = {})); export var SwitchBotBLEModel; (function (SwitchBotBLEModel) { SwitchBotBLEModel["Bot"] = "H"; SwitchBotBLEModel["Curtain"] = "c"; SwitchBotBLEModel["Curtain3"] = "{"; SwitchBotBLEModel["Humidifier"] = "e"; SwitchBotBLEModel["Humidifier2"] = "#"; SwitchBotBLEModel["Meter"] = "T"; SwitchBotBLEModel["MeterPlus"] = "i"; SwitchBotBLEModel["MeterPro"] = "4"; SwitchBotBLEModel["MeterProCO2"] = "5"; SwitchBotBLEModel["Hub2"] = "v"; SwitchBotBLEModel["Hub3"] = "V"; SwitchBotBLEModel["OutdoorMeter"] = "w"; SwitchBotBLEModel["MotionSensor"] = "s"; SwitchBotBLEModel["ContactSensor"] = "d"; SwitchBotBLEModel["ColorBulb"] = "u"; SwitchBotBLEModel["StripLight"] = "r"; SwitchBotBLEModel["PlugMiniUS"] = "g"; SwitchBotBLEModel["PlugMiniJP"] = "j"; SwitchBotBLEModel["Lock"] = "o"; SwitchBotBLEModel["LockPro"] = "$"; SwitchBotBLEModel["CeilingLight"] = "q"; SwitchBotBLEModel["CeilingLightPro"] = "n"; SwitchBotBLEModel["BlindTilt"] = "x"; SwitchBotBLEModel["Leak"] = "&"; SwitchBotBLEModel["Keypad"] = "y"; SwitchBotBLEModel["RelaySwitch1"] = ";"; SwitchBotBLEModel["RelaySwitch1PM"] = "<"; SwitchBotBLEModel["Remote"] = "b"; SwitchBotBLEModel["Unknown"] = "Unknown"; SwitchBotBLEModel["AirPurifier"] = "+"; SwitchBotBLEModel["AirPurifierTable"] = "7"; })(SwitchBotBLEModel || (SwitchBotBLEModel = {})); export var SwitchBotBLEModelName; (function (SwitchBotBLEModelName) { SwitchBotBLEModelName["Bot"] = "WoHand"; SwitchBotBLEModelName["Hub2"] = "WoHub2"; SwitchBotBLEModelName["Hub3"] = "WoHub3"; SwitchBotBLEModelName["ColorBulb"] = "WoBulb"; SwitchBotBLEModelName["Curtain"] = "WoCurtain"; SwitchBotBLEModelName["Curtain3"] = "WoCurtain3"; SwitchBotBLEModelName["Humidifier"] = "WoHumi"; SwitchBotBLEModelName["Humidifier2"] = "WoHumi2"; SwitchBotBLEModelName["Meter"] = "WoSensorTH"; SwitchBotBLEModelName["MeterPlus"] = "WoSensorTHPlus"; SwitchBotBLEModelName["MeterPro"] = "WoSensorTHP"; SwitchBotBLEModelName["MeterProCO2"] = "WoSensorTHPc"; SwitchBotBLEModelName["Lock"] = "WoSmartLock"; SwitchBotBLEModelName["LockPro"] = "WoSmartLockPro"; SwitchBotBLEModelName["PlugMini"] = "WoPlugMini"; SwitchBotBLEModelName["StripLight"] = "WoStrip"; SwitchBotBLEModelName["OutdoorMeter"] = "WoIOSensorTH"; SwitchBotBLEModelName["ContactSensor"] = "WoContact"; SwitchBotBLEModelName["MotionSensor"] = "WoMotion"; SwitchBotBLEModelName["BlindTilt"] = "WoBlindTilt"; SwitchBotBLEModelName["CeilingLight"] = "WoCeilingLight"; SwitchBotBLEModelName["CeilingLightPro"] = "WoCeilingLightPro"; SwitchBotBLEModelName["Leak"] = "WoLeakDetector"; SwitchBotBLEModelName["Keypad"] = "WoKeypad"; SwitchBotBLEModelName["RelaySwitch1"] = "WoRelaySwitch1Plus"; SwitchBotBLEModelName["RelaySwitch1PM"] = "WoRelaySwitch1PM"; SwitchBotBLEModelName["Remote"] = "WoRemote"; SwitchBotBLEModelName["AirPurifier"] = "WoAirPurifier"; SwitchBotBLEModelName["AirPurifierTable"] = "WoAirPurifierTable"; SwitchBotBLEModelName["Unknown"] = "Unknown"; })(SwitchBotBLEModelName || (SwitchBotBLEModelName = {})); export var SwitchBotBLEModelFriendlyName; (function (SwitchBotBLEModelFriendlyName) { SwitchBotBLEModelFriendlyName["Bot"] = "Bot"; SwitchBotBLEModelFriendlyName["Hub2"] = "Hub 2"; SwitchBotBLEModelFriendlyName["Hub3"] = "Hub 3"; SwitchBotBLEModelFriendlyName["ColorBulb"] = "Color Bulb"; SwitchBotBLEModelFriendlyName["Curtain"] = "Curtain"; SwitchBotBLEModelFriendlyName["Curtain3"] = "Curtain 3"; SwitchBotBLEModelFriendlyName["Humidifier"] = "Humidifier"; SwitchBotBLEModelFriendlyName["Humidifier2"] = "Humidifier2"; SwitchBotBLEModelFriendlyName["Meter"] = "Meter"; SwitchBotBLEModelFriendlyName["Lock"] = "Lock"; SwitchBotBLEModelFriendlyName["LockPro"] = "Lock Pro"; SwitchBotBLEModelFriendlyName["PlugMini"] = "Plug Mini"; SwitchBotBLEModelFriendlyName["StripLight"] = "Strip Light"; SwitchBotBLEModelFriendlyName["MeterPlus"] = "Meter Plus"; SwitchBotBLEModelFriendlyName["MeterPro"] = "Meter Pro"; SwitchBotBLEModelFriendlyName["MeterProCO2"] = "Meter Pro CO2"; SwitchBotBLEModelFriendlyName["BatteryCirculatorFan"] = "Battery Circulator Fan"; SwitchBotBLEModelFriendlyName["CirculatorFan"] = "Circulator Fan"; SwitchBotBLEModelFriendlyName["OutdoorMeter"] = "Outdoor Meter"; SwitchBotBLEModelFriendlyName["ContactSensor"] = "Contact Sensor"; SwitchBotBLEModelFriendlyName["MotionSensor"] = "Motion Sensor"; SwitchBotBLEModelFriendlyName["BlindTilt"] = "Blind Tilt"; SwitchBotBLEModelFriendlyName["CeilingLight"] = "Ceiling Light"; SwitchBotBLEModelFriendlyName["CeilingLightPro"] = "Ceiling Light Pro"; SwitchBotBLEModelFriendlyName["Leak"] = "Water Detector"; SwitchBotBLEModelFriendlyName["Keypad"] = "Keypad"; SwitchBotBLEModelFriendlyName["RelaySwitch1"] = "Relay Switch 1"; SwitchBotBLEModelFriendlyName["RelaySwitch1PM"] = "Relay Switch 1PM"; SwitchBotBLEModelFriendlyName["Remote"] = "Remote"; SwitchBotBLEModelFriendlyName["AirPurifier"] = "Air Purifier"; SwitchBotBLEModelFriendlyName["AirPurifierTable"] = "Air Purifier Table"; SwitchBotBLEModelFriendlyName["Unknown"] = "Unknown"; SwitchBotBLEModelFriendlyName["AirPurifierVOC"] = "Air Purifier VOC"; SwitchBotBLEModelFriendlyName["AirPurifierTableVOC"] = "Air Purifier Table VOC"; SwitchBotBLEModelFriendlyName["AirPurifierPM2_5"] = "Air Purifier PM2.5"; SwitchBotBLEModelFriendlyName["AirPurifierTablePM2_5"] = "Air Purifier Table PM2.5"; })(SwitchBotBLEModelFriendlyName || (SwitchBotBLEModelFriendlyName = {})); /** * Enum for log levels. */ export var LogLevel; (function (LogLevel) { LogLevel["SUCCESS"] = "success"; LogLevel["DEBUGSUCCESS"] = "debugsuccess"; LogLevel["WARN"] = "warn"; LogLevel["DEBUGWARN"] = "debugwarn"; LogLevel["ERROR"] = "error"; LogLevel["DEBUGERROR"] = "debugerror"; LogLevel["DEBUG"] = "debug"; LogLevel["INFO"] = "info"; })(LogLevel || (LogLevel = {})); /** * Utility class for comprehensive input validation with improved error messages. */ export class ValidationUtils { /** * Validates percentage value (0-100). * @param value - The value to validate * @param paramName - The parameter name for error reporting * @throws {RangeError} When value is not within valid range * @throws {TypeError} When value is not a number */ static validatePercentage(value, paramName = 'value') { if (typeof value !== 'number' || Number.isNaN(value)) { throw new TypeError(`${paramName} must be a valid number, got: ${value}`); } if (value < 0 || value > 100) { throw new RangeError(`${paramName} must be between 0 and 100 inclusive, got: ${value}`); } } /** * Validates RGB color value (0-255). * @param value - The color value to validate * @param colorName - The color name for error reporting * @throws {RangeError} When value is not within valid range * @throws {TypeError} When value is not a number */ static validateRGB(value, colorName = 'color') { if (typeof value !== 'number' || Number.isNaN(value)) { throw new TypeError(`${colorName} must be a valid number, got: ${value}`); } if (!Number.isInteger(value) || value < 0 || value > 255) { throw new RangeError(`${colorName} must be an integer between 0 and 255 inclusive, got: ${value}`); } } /** * Validates buffer and throws descriptive error. * @param buffer - The buffer to validate * @param expectedLength - Optional expected length * @param paramName - The parameter name for error reporting * @throws {TypeError} When buffer is not a Buffer * @throws {RangeError} When buffer length doesn't match expected */ static validateBuffer(buffer, expectedLength, paramName = 'buffer') { if (!Buffer.isBuffer(buffer)) { throw new TypeError(`${paramName} must be a Buffer instance, got: ${typeof buffer}`); } if (expectedLength !== undefined && buffer.length !== expectedLength) { throw new RangeError(`${paramName} must have exactly ${expectedLength} bytes, got: ${buffer.length} bytes`); } } /** * Validates string input with comprehensive checks. * @param value - The value to validate * @param paramName - The parameter name for error reporting * @param minLength - Minimum required length * @param maxLength - Optional maximum length * @throws {TypeError} When value is not a string * @throws {RangeError} When string length is invalid */ static validateString(value, paramName = 'value', minLength = 1, maxLength) { if (typeof value !== 'string') { throw new TypeError(`${paramName} must be a string, got: ${typeof value}`); } if (value.length < minLength) { throw new RangeError(`${paramName} must have at least ${minLength} character(s), got: ${value.length}`); } if (maxLength !== undefined && value.length > maxLength) { throw new RangeError(`${paramName} must have at most ${maxLength} character(s), got: ${value.length}`); } } /** * Validates numeric range with enhanced checks. * @param value - The value to validate * @param min - Minimum allowed value * @param max - Maximum allowed value * @param paramName - The parameter name for error reporting * @param mustBeInteger - Whether the value must be an integer * @throws {TypeError} When value is not a number * @throws {RangeError} When value is outside valid range */ static validateRange(value, min, max, paramName = 'value', mustBeInteger = false) { if (typeof value !== 'number' || Number.isNaN(value)) { throw new TypeError(`${paramName} must be a valid number, got: ${value}`); } if (mustBeInteger && !Number.isInteger(value)) { throw new TypeError(`${paramName} must be an integer, got: ${value}`); } if (value < min || value > max) { throw new RangeError(`${paramName} must be between ${min} and ${max} inclusive, got: ${value}`); } } /** * Validates MAC address format. * @param address - The MAC address to validate * @param paramName - The parameter name for error reporting * @throws {TypeError} When address is not a string * @throws {Error} When address format is invalid */ static validateMacAddress(address, paramName = 'address') { if (typeof address !== 'string') { throw new TypeError(`${paramName} must be a string`); } const macRegex = /^(?:[0-9A-F]{2}[:-]){5}[0-9A-F]{2}$|^[0-9A-F]{12}$/i; if (!macRegex.test(address)) { throw new Error(`${paramName} must be a valid MAC address format, got: ${address}`); } } /** * Validates that a value is one of the allowed enum values. * @param value - The value to validate * @param allowedValues - Array of allowed values * @param paramName - The parameter name for error reporting * @throws {Error} When value is not in allowed values */ static validateEnum(value, allowedValues, paramName = 'value') { if (!allowedValues.includes(value)) { throw new Error(`${paramName} must be one of: ${allowedValues.join(', ')}, got: ${value}`); } } } /** * Enhanced error handling utilities. */ export class ErrorUtils { /** * Creates a timeout error with context. * @param operation - The operation that timed out * @param timeoutMs - The timeout duration in milliseconds * @returns A descriptive timeout error */ static createTimeoutError(operation, timeoutMs) { return new Error(`Operation '${operation}' timed out after ${timeoutMs}ms`); } /** * Creates a connection error with context. * @param deviceId - The device ID that failed to connect * @param cause - The underlying cause of the connection failure * @returns A descriptive connection error */ static createConnectionError(deviceId, cause) { const message = `Failed to connect to device ${deviceId}`; return cause ? new Error(`${message}: ${cause.message}`) : new Error(message); } /** * Creates a command error with context. * @param command - The command that failed * @param deviceId - The device ID * @param cause - The underlying cause * @returns A descriptive command error */ static createCommandError(command, deviceId, cause) { const message = `Command '${command}' failed for device ${deviceId}`; return cause ? new Error(`${message}: ${cause.message}`) : new Error(message); } /** * Wraps an async operation with timeout and enhanced error handling. * @param operation - The async operation to wrap * @param timeoutMs - Timeout in milliseconds * @param operationName - Name of the operation for error messages * @returns Promise that resolves with the operation result or rejects with timeout */ static async withTimeout(operation, timeoutMs, operationName) { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(this.createTimeoutError(operationName, timeoutMs)); }, timeoutMs); }); return Promise.race([operation, timeoutPromise]); } } /** * Represents a Switchbot Device. */ export class SwitchbotDevice extends EventEmitter { noble; peripheral; characteristics = null; deviceId; deviceAddress; deviceModel; deviceModelName; deviceFriendlyName; explicitlyConnected = false; isConnected = false; onNotify = () => { }; onDisconnect = async () => { }; onConnect = async () => { }; /** * Initializes a new instance of the SwitchbotDevice class. * @param peripheral The peripheral object from noble. * @param noble The Noble object. */ constructor(peripheral, noble) { super(); this.peripheral = peripheral; this.noble = noble; Advertising.parse(peripheral, this.log.bind(this)).then((ad) => { this.deviceId = ad?.id ?? ''; this.deviceAddress = ad?.address ?? ''; this.deviceModel = ad?.serviceData.model ?? ''; this.deviceModelName = ad?.serviceData.modelName ?? ''; this.deviceFriendlyName = ad?.serviceData.modelFriendlyName ?? ''; }); } /** * Logs a message with the specified log level. * @param level The severity level of the log (e.g., 'info', 'warn', 'error'). * @param message The log message to be emitted. */ async log(level, message) { this.emit('log', { level, message }); } // Getters get id() { return this.deviceId; } get address() { return this.deviceAddress; } get model() { return this.deviceModel; } get modelName() { return this.deviceModelName; } get friendlyName() { return this.deviceFriendlyName; } get connectionState() { return this.isConnected ? 'connected' : this.peripheral.state; } get onConnectHandler() { return this.onConnect; } set onConnectHandler(func) { if (typeof func !== 'function') { throw new TypeError('The `onConnectHandler` must be a function that returns a Promise<void>.'); } this.onConnect = async () => { await func(); }; } get onDisconnectHandler() { return this.onDisconnect; } set onDisconnectHandler(func) { if (typeof func !== 'function') { throw new TypeError('The `onDisconnectHandler` must be a function that returns a Promise<void>.'); } this.onDisconnect = async () => { await func(); }; } /** * Connects to the device. * @returns A Promise that resolves when the connection is complete. */ async connect() { this.explicitlyConnected = true; await this.internalConnect(); } /** * Internal method to handle the connection process. * @returns A Promise that resolves when the connection is complete. */ async internalConnect() { if (this.noble.state !== 'poweredOn') { throw new Error(`The Bluetooth status is ${this.noble.state}, not poweredOn.`); } const state = this.connectionState; if (state === 'connected') { return; } if (state === 'connecting' || state === 'disconnecting') { throw new Error(`Now ${state}. Wait for a few seconds then try again.`); } this.peripheral.once('connect', async () => { this.isConnected = true; await this.onConnect(); }); this.peripheral.once('disconnect', async () => { this.isConnected = false; this.characteristics = null; this.peripheral.removeAllListeners(); await this.onDisconnect(); }); await this.peripheral.connectAsync(); this.characteristics = await this.getCharacteristics(); await this.subscribeToNotify(); } /** * Retrieves the device characteristics. * @returns A Promise that resolves with the device characteristics. */ async getCharacteristics() { const TIMEOUT_DURATION = 5000; const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error('Failed to discover services and characteristics: TIMEOUT')); }, TIMEOUT_DURATION); }); try { const services = await Promise.race([this.discoverServices(), timeoutPromise]); const chars = { write: null, notify: null, device: null }; for (const service of services) { const characteristics = await this.discoverCharacteristics(service); for (const char of characteristics) { if (char.uuid === CHAR_UUID_WRITE) { chars.write = char; } if (char.uuid === CHAR_UUID_NOTIFY) { chars.notify = char; } if (char.uuid === CHAR_UUID_DEVICE) { chars.device = char; } } } if (!chars.write || !chars.notify) { throw new Error('No characteristic was found.'); } return chars; } catch (error) { throw new Error(error.message || 'An error occurred while discovering characteristics.'); } } /** * Discovers the device services. * @returns A Promise that resolves with the list of services. */ async discoverServices() { try { const services = await this.peripheral.discoverServicesAsync([]); const primaryServices = services.filter(s => s.uuid === SERV_UUID_PRIMARY); if (primaryServices.length === 0) { throw new Error('No service was found.'); } return primaryServices; } catch (e) { throw new Error(`Failed to discover services, Error: ${e.message ?? e}`); } } /** * Discovers the characteristics of a service. * @param service The service to discover characteristics for. * @returns A Promise that resolves with the list of characteristics. */ // Discover characteristics without extra async/await discoverCharacteristics(service) { return service.discoverCharacteristicsAsync([]); } /** * Subscribes to the notify characteristic. * @returns A Promise that resolves when the subscription is complete. */ async subscribeToNotify() { const char = this.characteristics?.notify; if (!char) { throw new Error('No notify characteristic was found.'); } await char.subscribeAsync(); char.on('data', (buf) => this.onNotify(buf)); } /** * Unsubscribes from the notify characteristic. * @returns A Promise that resolves when the unsubscription is complete. */ async unsubscribeFromNotify() { const char = this.characteristics?.notify; if (!char) { return; } char.removeAllListeners(); await char.unsubscribeAsync(); } /** * Disconnects from the device. * @returns A Promise that resolves when the disconnection is complete. */ async disconnect() { this.explicitlyConnected = false; const state = this.peripheral.state; if (state === 'disconnected') { return; } if (state === 'connecting' || state === 'disconnecting') { throw new Error(`Now ${state}. Wait for a few seconds then try again.`); } await this.unsubscribeFromNotify(); await this.peripheral.disconnectAsync(); } /** * Internal method to handle disconnection if not explicitly initiated. * @returns A Promise that resolves when the disconnection is complete. */ async internalDisconnect() { if (!this.explicitlyConnected) { await this.disconnect(); this.explicitlyConnected = true; } } /** * Retrieves the device name. * @returns A Promise that resolves with the device name. */ async getDeviceName() { await this.internalConnect(); try { if (!this.characteristics?.device) { throw new Error(`Characteristic ${CHAR_UUID_DEVICE} not supported`); } const buf = await this.readCharacteristic(this.characteristics.device); return buf.toString('utf8'); } catch (error) { const deviceContext = `device ${this.deviceId || 'unknown'}`; throw ErrorUtils.createCommandError('getDeviceName', deviceContext, error); } finally { await this.internalDisconnect(); } } /** * Sets the device name. * @param name The new device name. * @returns A Promise that resolves when the name is set. */ async setDeviceName(name) { ValidationUtils.validateString(name, 'name', 1); // Additional validation for device name length const nameBuffer = Buffer.from(name, 'utf8'); if (nameBuffer.length > 100) { throw new RangeError('Device name cannot exceed 100 bytes when encoded as UTF-8'); } await this.internalConnect(); try { if (!this.characteristics?.device) { throw new Error(`Characteristic ${CHAR_UUID_DEVICE} not supported`); } await this.writeCharacteristic(this.characteristics.device, nameBuffer); } catch (error) { const deviceContext = `device ${this.deviceId || 'unknown'}`; throw ErrorUtils.createCommandError('setDeviceName', deviceContext, error); } finally { await this.internalDisconnect(); } } /** * Sends a command to the device and awaits a response. * @param reqBuf The command buffer. * @returns A Promise that resolves with the response buffer. */ async command(reqBuf) { ValidationUtils.validateBuffer(reqBuf, undefined, 'reqBuf'); await this.internalConnect(); if (!this.characteristics?.write) { throw new Error('No write characteristic available for command execution'); } try { await this.writeCharacteristic(this.characteristics.write, reqBuf); const resBuf = await this.waitForCommandResponse(); return resBuf; } catch (error) { const deviceContext = `device ${this.deviceId || 'unknown'}`; // Use ErrorUtils for enriched error context throw ErrorUtils.createCommandError('execute command', deviceContext, error); } finally { await this.internalDisconnect(); } } /** * Waits for a response from the device after sending a command. * @returns A Promise that resolves with the response buffer. */ async waitForCommandResponse() { const timeout = READ_TIMEOUT_MSEC; let timer = null; const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('READ_TIMEOUT')), timeout); }); const readPromise = new Promise((resolve) => { this.onNotify = (buf) => { if (timer) { clearTimeout(timer); } resolve(buf); }; }); return await Promise.race([readPromise, timeoutPromise]); } /** * Reads data from a characteristic with enhanced timeout and error handling. * @param char The characteristic to read from. * @returns A Promise that resolves with the data buffer. */ async readCharacteristic(char) { try { return await ErrorUtils.withTimeout(char.readAsync(), READ_TIMEOUT_MSEC, `read characteristic ${char.uuid}`); } catch (error) { const deviceContext = `device ${this.deviceId || 'unknown'}`; throw ErrorUtils.createCommandError(`read characteristic ${char.uuid}`, deviceContext, error); } } /** * Writes data to a characteristic with enhanced timeout and error handling. * @param char The characteristic to write to. * @param buf The data buffer. * @returns A Promise that resolves when the write is complete. */ async writeCharacteristic(char, buf) { ValidationUtils.validateBuffer(buf, undefined, 'write buffer'); try { return await ErrorUtils.withTimeout(char.writeAsync(buf, false), WRITE_TIMEOUT_MSEC, `write to characteristic ${char.uuid}`); } catch (error) { const deviceContext = `device ${this.deviceId || 'unknown'}`; throw ErrorUtils.createCommandError(`write to characteristic ${char.uuid}`, deviceContext, error); } } } /** * Represents the advertising data parser for SwitchBot devices. */ export class Advertising { constructor() { } /** * Parses the advertisement data coming from SwitchBot device. * * This function processes advertising packets received from SwitchBot devices * and extracts relevant information based on the device type. * * @param {NobleTypes['peripheral']} peripheral - The peripheral device object from noble. * @param {Function} emitLog - The function to emit log messages. * @returns {Promise<Ad | null>} - An object containing parsed data specific to the SwitchBot device type, or `null` if the device is not recognized. */ static async parse(peripheral, emitLog) { const ad = peripheral.advertisement; if (!ad || !ad.serviceData) { return null; } const serviceData = ad.serviceData[0]?.data; const manufacturerData = ad.manufacturerData; if (!Advertising.validateBuffer(serviceData) || !Advertising.validateBuffer(manufacturerData)) { return null; } const model = serviceData.subarray(0, 1).toString('utf8'); const sd = await Advertising.parseServiceData(model, serviceData, manufacturerData, emitLog); if (!sd) { // emitLog('debugerror', `[parseAdvertising.${peripheral.id}.${model}] return null, parsed serviceData empty!`) return null; } const address = Advertising.formatAddress(peripheral); const data = { id: peripheral.id, address, rssi: peripheral.rssi, serviceData: { model, modelName: sd.modelName || '', modelFriendlyName: sd.modelFriendlyName || '', ...sd, }, }; emitLog('debug', `[parseAdvertising.${peripheral.id}.${model}] return ${JSON.stringify(data)}`); return data; } /** * Validates if the buffer is a valid Buffer object with a minimum length. * * @param {any} buffer - The buffer to validate. * @param {number} minLength - The minimum required length. * @returns {boolean} - True if the buffer is valid, false otherwise. */ static validateBuffer(buffer, minLength = 3) { return buffer && Buffer.isBuffer(buffer) && buffer.length >= minLength; } /** * Parses the service data based on the device model. * * @param {string} model - The device model. * @param {Buffer} serviceData - The service data buffer. * @param {Buffer} manufacturerData - The manufacturer data buffer. * @param {Function} emitLog - The function to emit log messages. * @returns {Promise<any>} - The parsed service data. */ static async parseServiceData(model, serviceData, manufacturerData, emitLog) { switch (model) { case SwitchBotBLEModel.Bot: return WoHand.parseServiceData(serviceData, emitLog); case SwitchBotBLEModel.Curtain: case SwitchBotBLEModel.Curtain3: return WoCurtain.parseServiceData(serviceData, manufacturerData, emitLog); case SwitchBotBLEModel.Humidifier: return WoHumi.parseServiceData(serviceData, emitLog); case SwitchBotBLEModel.Humidifier2: return WoHumi2.parseServiceData(serviceData, emitLog); case SwitchBotBLEModel.Meter: return WoSensorTH.parseServiceData(serviceData, emitLog); case SwitchBotBLEModel.MeterPlus: return WoSensorTHPlus.parseServiceData(serviceData, emitLog); case SwitchBotBLEModel.MeterPro: return WoSensorTHPro.parseServiceData(serviceData, emitLog); case SwitchBotBLEModel.MeterProCO2: return WoSensorTHProCO2.parseServiceData(serviceData, manufacturerData, emitLog); case SwitchBotBLEModel.Hub2: return WoHub2.parseServiceData(manufacturerData, emitLog); case SwitchBotBLEModel.Hub3: return WoHub3.parseServiceData(manufacturerData, emitLog); case SwitchBotBLEModel.OutdoorMeter: return WoIOSensorTH.parseServiceData(serviceData, manufacturerData, emitLog); case SwitchBotBLEModel.AirPurifier: return WoAirPurifier.parseServiceData(serviceData, manufacturerData, emitLog); case SwitchBotBLEModel.AirPurifierTable: return WoAirPurifierTable.parseServiceData(serviceData, manufacturerData, emitLog); case SwitchBotBLEModel.MotionSensor: return WoPresence.parseServiceData(serviceData, emitLog); case SwitchBotBLEModel.ContactSensor: return WoContact.parseServiceData(serviceData, emitLog); case SwitchBotBLEModel.Remote: return WoRemote.parseServiceData(serviceData, emitLog); case SwitchBotBLEModel.ColorBulb: return WoBulb.parseServiceData(serviceData, manufacturerData, emitLog); case SwitchBotBLEModel.CeilingLight: return WoCeilingLight.parseServiceData(manufacturerData, emitLog); case SwitchBotBLEModel.CeilingLightPro: return WoCeilingLight.parseServiceData_Pro(manufacturerData, emitLog); case SwitchBotBLEModel.StripLight: return WoStrip.parseServiceData(serviceData, emitLog); case SwitchBotBLEModel.PlugMiniUS: return WoPlugMiniUS.parseServiceData(manufacturerData, emitLog); case SwitchBotBLEModel.PlugMiniJP: return WoPlugMiniJP.parseServiceData(manufacturerData, emitLog); case SwitchBotBLEModel.Lock: return WoSmartLock.parseServiceData(serviceData, manufacturerData, emitLog); case SwitchBotBLEModel.LockPro: return WoSmartLockPro.parseServiceData(serviceData, manufacturerData, emitLog); case SwitchBotBLEModel.BlindTilt: return WoBlindTilt.parseServiceData(serviceData, manufacturerData, emitLog); case SwitchBotBLEModel.Leak: return WoLeak.parseServiceData(serviceData, manufacturerData, emitLog); case SwitchBotBLEModel.RelaySwitch1: return WoRelaySwitch1.parseServiceData(serviceData, manufacturerData, emitLog); case SwitchBotBLEModel.RelaySwitch1PM: return WoRelaySwitch1PM.parseServiceData(serviceData, manufacturerData, emitLog); default: emitLog('debug', `[parseAdvertising.${model}] return null, model "${model}" not available!`); return null; } } /** * Formats the address of the peripheral. * * @param {NobleTypes['peripheral']} peripheral - The peripheral device object from noble. * @returns {string} - The formatted address. */ static formatAddress(peripheral) { let address = peripheral.address || ''; if (address === '') { const str = peripheral.advertisement.manufacturerData?.toString('hex').slice(4, 16) || ''; if (str !== '') { address = str.match(/.{1,2}/g)?.join(':') || ''; } } else { address = address.replace(/-/g, ':'); } return address; } } /** * Class representing a WoBlindTilt device. * @see https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/latest/devicetypes/curtain.md */ export class WoBlindTilt extends SwitchbotDevice { reverse = false; /** * Parses the service data and manufacturer data for the WoBlindTilt device. * @param {Buffer} serviceData - The service data buffer. * @param {Buffer} manufacturerData - The manufacturer data buffer. * @param {Function} emitLog - The function to emit log messages. * @param {boolean} [reverse] - Whether to reverse the tilt percentage. * @returns {Promise<blindTiltServiceData | null>} - The parsed data object or null if the data is invalid. */ static async parseServiceData(serviceData, manufacturerData, emitLog, reverse = false) { if (![5, 6].includes(manufacturerData.length)) { emitLog('debugerror', `[parseServiceDataForWoBlindTilt] Buffer length ${manufacturerData.length} !== 5 or 6!`); return null; } const byte2 = serviceData.readUInt8(2); const byte6 = manufacturerData.subarray(6); const tilt = Math.max(Math.min(byte6.readUInt8(2) & 0b01111111, 100), 0); const inMotion = !!(byte2 & 0b10000000); const lightLevel = (byte6.readUInt8(1) >> 4) & 0b00001111; const calibration = !!(byte6.readUInt8(1) & 0b00000001); const sequenceNumber = byte6.readUInt8(0); const battery = serviceData.length > 2 ? byte2 & 0b01111111 : 0; const data = { model: SwitchBotBLEModel.BlindTilt, modelName: SwitchBotBLEModelName.BlindTilt, modelFriendlyName: SwitchBotBLEModelFriendlyName.BlindTilt, calibration, battery, inMotion, tilt: reverse ? 100 - tilt : tilt, lightLevel, sequenceNumber, }; return data; } constructor(peripheral, noble) { super(peripheral, noble); } /** * Opens the blind tilt to the fully open position. * @returns {Promise<void>} */ async open() { await this.operateBlindTilt([...BLIND_TILT_COMMANDS.OPEN]); } /** * Closes the blind tilt up to the nearest endpoint. * @returns {Promise<void>} */ async closeUp() { await this.operateBlindTilt([...BLIND_TILT_COMMANDS.CLOSE_UP]); } /** * Closes the blind tilt down to the nearest endpoint. * @returns {Promise<void>} */ async closeDown() { await this.operateBlindTilt([...BLIND_TILT_COMMANDS.CLOSE_DOWN]); } /** * Closes the blind tilt to the nearest endpoint. * @returns {Promise<void>} */ async close() { const position = await this.getPosition(); if (position > 50) { await this.closeUp(); } else { await this.closeDown(); } } /** * Retrieves the current position of the blind tilt. * @returns {Promise<number>} - The current position of the blind tilt (0-100). */ async getPosition() { const tiltPosition = await this._getAdvValue('tilt'); return Math.max(0, Math.min(tiltPosition, 100)); } /** * Retrieves the advertised value for a given key. * @param {string} key - The key for the advertised value. * @returns {Promise<number>} - The advertised value. * @private */ async _getAdvValue(key) { if (key === 'tilt') { return 50; // Example value } throw new Error(`Unknown key: ${key}`); } /** * Retrieves the basic information of the blind tilt. * @returns {Promise<object | null>} - A promise that resolves to an object containing the basic information of the blind tilt. */ async getBasicInfo() { const data = await this.getBasicInfo(); if (!data) { return null; } const tilt = Math.max(Math.min(data[6], 100), 0); const moving = Boolean(data[5] & 0b00000011); let opening = false; let closing = false; let up = false; if (moving) { opening = Boolean(data[5] & 0b00000010); closing = !opening && Boolean(data[5] & 0b00000001); if (opening) { const flag = Boolean(data[5] & 0b00000001); up = flag ? this.reverse : !flag; } else { up = tilt < 50 ? this.reverse : tilt > 50; } } return { battery: data[1], firmware: data[2] / 10.0, light: Boolean(data[4] & 0b00100000), fault: Boolean(data[4] & 0b00001000), solarPanel: Boolean(data[5] & 0b00001000), calibration: Boolean(data[5] & 0b00000100), calibrated: Boolean(data[5] & 0b00000100), inMotion: moving, motionDirection: { opening: moving && opening, closing: moving && closing, up: moving && up, down: moving && !up, }, tilt: this.reverse ? 100 - tilt : tilt, timers: data[7], }; } /** * Pauses the blind tilt operation. * @returns {Promise<void>} */ async pause() { await this.operateBlindTilt([...BLIND_TILT_COMMANDS.PAUSE]); } /** * Runs the blind tilt to the specified position. * @param {number} percent - The target position percentage (0-100). * @param {number} mode - The running mode (0 or 1). * @returns {Promise<void>} */ async runToPos(percent, mode) { ValidationUtils.validatePercentage(percent, 'percent'); ValidationUtils.validateRange(mode, 0, 1, 'mode', true); const adjustedPercent = this.reverse ? 100 - percent : percent; await this.operateBlindTilt([0x57, 0x0F, 0x45, 0x01, 0x05, mode, adjustedPercent]); } /** * Sends a command to operate the blind tilt and handles the response. * @param {number[]} bytes - The byte array representing the command to be sent to the device. * @returns {Promise<void>} * @private */ async operateBlindTilt(bytes) { const reqBuf = Buffer.from(bytes); const resBuf = await this.command(reqBuf); if (resBuf.length !== 3 || resBuf.readUInt8(0) !== 0x01) { throw new Error(`The device returned an error: 0x${resBuf.toString('hex')}`); } } } /** * Class representing a WoBulb device. * @see https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/latest/devicetypes/colorbulb.md */ export class WoBulb extends SwitchbotDevice { /** * Parses the service data for WoBulb. * @param {Buffer} serviceData - The service data buffer. * @param {Buffer} manufacturerData - The manufacturer data buffer. * @param {Function} emitLog - The function to emit log messages. * @returns {Promise<colorBulbServiceData | null>} - Parsed service data or null if invalid. */ static async parseServiceData(serviceData, manufacturerData, // eslint-disable-next-line unused-imports/no-unused-vars emitLog) { if (serviceData.length !== 18) { // emitLog('debugerror', `[parseServiceDataForWoBulb] Buffer length ${serviceData.length} !== 18!`) return null; } if (manufacturerData.length !== 13) { // emitLog('debugerror', `[parseServiceDataForWoBulb] Buffer length ${manufacturerData.length} !== 13!`) return null; } const [, byte1, , byte3, byte4, byte5, byte6, byte7, byte8, byte9, byte10,] = manufacturerData; const data = { model: SwitchBotBLEModel.ColorBulb, modelName: SwitchBotBLEModelName.ColorBulb, modelFriendlyName: SwitchBotBLEModelFriendlyName.ColorBulb, power: !!byte1, red: byte3, green: byte4, blue: byte5, color_temperature: byte6, state: !!(byte7 & 0b01111111), brightness: byte7 & 0b01111111, delay: (byte8 & 0b10000000) >> 7, preset: (byte8 & 0b00001000) >> 3, color_mode: byte8 & 0b00000111, speed: byte9 & 0b01111111, loop_index: byte10 & 0b11111110, }; return data; } constructor(peripheral, noble) { super(peripheral, noble); } /** * Reads the state of the bulb. * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the bulb is ON (true) or OFF (false). */ async readState() { return this.operateBulb([...BULB_COMMANDS.READ_STATE]); } /** * Sets the state of the bulb. * @param {number[]} reqByteArray - The request byte array. * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the operation was successful. * @private */ async setState(reqByteArray) { return this.operateBulb([...BULB_COMMANDS.BASE, ...reqByteArray]); } /** * Turns on the bulb. * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the bulb is ON (true). */ async turnOn() { return this.setState([...BULB_COMMANDS.TURN_ON]); } /** * Turns off the bulb. * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the bulb is OFF (false). */ async turnOff() { return this.setState([...BULB_COMMANDS.TURN_OFF]); } /** * Sets the brightness of the bulb. * @param {number} brightness - The brightness percentage (0-100). * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the operation was successful. */ async setBrightness(brightness) { ValidationUtils.validatePercentage(brightness, 'brightness'); return this.setState([...BULB_COMMANDS.SET_BRIGHTNESS, brightness]); } /** * Sets the color temperature of the bulb. * @param {number} color_temperature - The color temperature percentage (0-100). * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the operation was successful. */ async setColorTemperature(color_temperature) { ValidationUtils.validatePercentage(color_temperature, 'color_temperature'); return this.setState([...BULB_COMMANDS.SET_COLOR_TEMP, color_temperature]); } /** * Sets the RGB color of the bulb. * @param {number} brightness - The brightness percentage (0-100). * @param {number} red - The red color value (0-255). * @param {number} green - The green color value (0-255). * @param {number} blue - The blue color value (0-255). * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the operation was successful. */ async setRGB(brightness, red, green, blue) { ValidationUtils.validatePercenta