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
JavaScript
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