UNPKG

node-switchbot

Version:

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

1,199 lines (1,198 loc) 113 kB
import { Buffer } from 'node:buffer'; import * as Crypto from 'node:crypto'; import { EventEmitter } from 'node:events'; import { parameterChecker } from './parameter-checker.js'; import { CHAR_UUID_DEVICE, CHAR_UUID_NOTIFY, CHAR_UUID_WRITE, READ_TIMEOUT_MSEC, SERV_UUID_PRIMARY, WoSmartLockCommands, WoSmartLockProCommands, WRITE_TIMEOUT_MSEC } from './settings.js'; const HUMIDIFIER_COMMAND_HEADER = '5701'; const TURN_ON_KEY = `${HUMIDIFIER_COMMAND_HEADER}0101`; const TURN_OFF_KEY = `${HUMIDIFIER_COMMAND_HEADER}0102`; const INCREASE_KEY = `${HUMIDIFIER_COMMAND_HEADER}0103`; const DECREASE_KEY = `${HUMIDIFIER_COMMAND_HEADER}0104`; const SET_AUTO_MODE_KEY = `${HUMIDIFIER_COMMAND_HEADER}0105`; const SET_MANUAL_MODE_KEY = `${HUMIDIFIER_COMMAND_HEADER}0106`; export var SwitchBotModel; (function (SwitchBotModel) { SwitchBotModel["HubMini"] = "W0202200"; SwitchBotModel["HubPlus"] = "SwitchBot Hub S1"; SwitchBotModel["Hub2"] = "W3202100"; 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 || (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["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 || (SwitchBotBLEModel = {})); export var SwitchBotBLEModelName; (function (SwitchBotBLEModelName) { SwitchBotBLEModelName["Bot"] = "WoHand"; SwitchBotBLEModelName["Hub2"] = "WoHub2"; 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["Unknown"] = "Unknown"; })(SwitchBotBLEModelName || (SwitchBotBLEModelName = {})); export var SwitchBotBLEModelFriendlyName; (function (SwitchBotBLEModelFriendlyName) { SwitchBotBLEModelFriendlyName["Bot"] = "Bot"; SwitchBotBLEModelFriendlyName["Hub2"] = "Hub 2"; 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["Unknown"] = "Unknown"; })(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 = {})); /** * 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. */ async discoverCharacteristics(service) { return await 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(); if (!this.characteristics?.device) { throw new Error(`The device does not support the characteristic UUID 0x${CHAR_UUID_DEVICE}.`); } const buf = await this.readCharacteristic(this.characteristics.device); await this.internalDisconnect(); return buf.toString('utf8'); } /** * Sets the device name. * @param name The new device name. * @returns A Promise that resolves when the name is set. */ async setDeviceName(name) { const valid = parameterChecker.check({ name }, { name: { required: true, type: 'string', minBytes: 1, maxBytes: 100 } }, true); if (!valid) { throw new Error(parameterChecker.error.message); } const buf = Buffer.from(name, 'utf8'); await this.internalConnect(); if (!this.characteristics?.device) { throw new Error(`The device does not support the characteristic UUID 0x${CHAR_UUID_DEVICE}.`); } await this.writeCharacteristic(this.characteristics.device, buf); 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) { if (!Buffer.isBuffer(reqBuf)) { throw new TypeError('The specified data is not acceptable for writing.'); } await this.internalConnect(); if (!this.characteristics?.write) { throw new Error('No characteristics available.'); } await this.writeCharacteristic(this.characteristics.write, reqBuf); const resBuf = await this.waitForCommandResponse(); await this.internalDisconnect(); return resBuf; } /** * 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 a timeout. * @param char The characteristic to read from. * @returns A Promise that resolves with the data buffer. */ async readCharacteristic(char) { const timer = setTimeout(() => { throw new Error('READ_TIMEOUT'); }, READ_TIMEOUT_MSEC); try { const result = await char.readAsync(); clearTimeout(timer); return result; } catch (error) { clearTimeout(timer); throw error; } } /** * Writes data to a characteristic with a timeout. * @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) { const timer = setTimeout(() => { throw new Error('WRITE_TIMEOUT'); }, WRITE_TIMEOUT_MSEC); try { await char.writeAsync(buf, false); clearTimeout(timer); } catch (error) { clearTimeout(timer); throw 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. * @returns {boolean} - True if the buffer is valid, false otherwise. */ static validateBuffer(buffer) { return buffer && Buffer.isBuffer(buffer) && buffer.length >= 3; } /** * 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.OutdoorMeter: return WoIOSensorTH.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([0x57, 0x0F, 0x45, 0x01, 0x05, 0xFF, 0x32]); } /** * Closes the blind tilt up to the nearest endpoint. * @returns {Promise<void>} */ async closeUp() { await this.operateBlindTilt([0x57, 0x0F, 0x45, 0x01, 0x05, 0xFF, 0x64]); } /** * Closes the blind tilt down to the nearest endpoint. * @returns {Promise<void>} */ async closeDown() { await this.operateBlindTilt([0x57, 0x0F, 0x45, 0x01, 0x05, 0xFF, 0x00]); } /** * 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([0x57, 0x0F, 0x45, 0x01, 0x00, 0xFF]); } /** * 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) { if (typeof percent !== 'number' || percent < 0 || percent > 100) { throw new RangeError('Percent must be a number between 0 and 100'); } if (typeof mode !== 'number' || mode < 0 || mode > 1) { throw new RangeError('Mode must be a number between 0 and 1'); } await this.operateBlindTilt([0x57, 0x0F, 0x45, 0x01, 0x05, mode, percent]); } /** * 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([0x57, 0x0F, 0x48, 0x01]); } /** * 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) { const base = [0x57, 0x0F, 0x47, 0x01]; return this.operateBulb(base.concat(reqByteArray)); } /** * Turns on the bulb. * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the bulb is ON (true). */ async turnOn() { return this.setState([0x01, 0x01]); } /** * Turns off the bulb. * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the bulb is OFF (false). */ async turnOff() { return this.setState([0x01, 0x02]); } /** * 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) { if (brightness < 0 || brightness > 100) { throw new RangeError('Brightness must be between 0 and 100'); } return this.setState([0x02, 0x14, 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) { if (color_temperature < 0 || color_temperature > 100) { throw new RangeError('Color temperature must be between 0 and 100'); } return this.setState([0x02, 0x17, 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) { if (brightness < 0 || brightness > 100 || red < 0 || red > 255 || green < 0 || green > 255 || blue < 0 || blue > 255) { throw new RangeError('Invalid RGB or brightness values'); } return this.setState([0x02, 0x12, brightness, red, green, blue]); } /** * Sends a command to the bulb. * @param {number[]} bytes - The command bytes. * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the operation was successful. * @private */ async operateBulb(bytes) { const reqBuf = Buffer.from(bytes); const resBuf = await this.command(reqBuf); if (resBuf.length === 2) { const code = resBuf.readUInt8(1); if (code === 0x00 || code === 0x80) { return code === 0x80; } throw new Error(`The device returned an error: 0x${resBuf.toString('hex')}`); } throw new Error(`Expecting a 2-byte response, got instead: 0x${resBuf.toString('hex')}`); } } /** * Class representing a WoCeilingLight device. * @see https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/latest/devicetypes/colorbulb.md */ export class WoCeilingLight extends SwitchbotDevice { /** * Parses the service data for WoCeilingLight. * @param {Buffer} manufacturerData - The manufacturer data buffer. * @param {Function} emitLog - The function to emit log messages. * @returns {Promise<ceilingLightServiceData | null>} - Parsed service data or null if invalid. */ static async parseServiceData(manufacturerData, emitLog) { if (manufacturerData.length !== 13) { emitLog('debugerror', `[parseServiceDataForWoCeilingLight] Buffer length ${manufacturerData.length} !== 13!`); return null; } const [, byte1, , byte3, byte4, byte5, byte6, byte7, byte8, byte9, byte10,] = manufacturerData; const data = { model: SwitchBotBLEModel.CeilingLight, modelName: SwitchBotBLEModelName.CeilingLight, modelFriendlyName: SwitchBotBLEModelFriendlyName.CeilingLight, power: !!byte1, red: byte3, green: byte4, blue: byte5, color_temperature: byte6, state: !!(byte7 & 0b01111111), brightness: byte7 & 0b01111111, delay: (byte8 & 0b10000000) ? 1 : 0, preset: (byte8 & 0b00001000) ? 1 : 0, color_mode: byte8 & 0b00000111, speed: byte9 & 0b01111111, loop_index: byte10 & 0b11111110, }; return data; } /** * Parses the service data for WoCeilingLight Pro. * @param {Buffer} manufacturerData - The manufacturer data buffer. * @param {Function} emitLog - The function to emit log messages. * @returns {Promise<ceilingLightProServiceData | null>} - Parsed service data or null if invalid. */ static async parseServiceData_Pro(manufacturerData, emitLog) { if (manufacturerData.length !== 13) { emitLog('debugerror', `[parseServiceDataForWoCeilingLightPro] Buffer length ${manufacturerData.length} !== 13!`); return null; } const [, byte1, , byte3, byte4, byte5, byte6, byte7, byte8, byte9, byte10,] = manufacturerData; const data = { model: SwitchBotBLEModel.CeilingLightPro, modelName: SwitchBotBLEModelName.CeilingLightPro, modelFriendlyName: SwitchBotBLEModelFriendlyName.CeilingLightPro, power: !!byte1, red: byte3, green: byte4, blue: byte5, color_temperature: byte6, state: !!(byte7 & 0b01111111), brightness: byte7 & 0b01111111, delay: (byte8 & 0b10000000) ? 1 : 0, preset: (byte8 & 0b00001000) ? 1 : 0, color_mode: byte8 & 0b00000111, speed: byte9 & 0b01111111, loop_index: byte10 & 0b11111110, }; return data; } constructor(peripheral, noble) { super(peripheral, noble); } /** * Reads the state of the ceiling light. * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the light is ON (true) or OFF (false). */ async readState() { return this.operateCeilingLight([0x57, 0x0F, 0x48, 0x01]); } /** * Sets the state of the ceiling light. * @param {number[]} reqByteArray - The request byte array. * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the operation was successful. */ async setState(reqByteArray) { const base = [0x57, 0x0F, 0x47, 0x01]; return this.operateCeilingLight(base.concat(reqByteArray)); } /** * Turns on the ceiling light. * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the light is ON (true). */ async turnOn() { return this.setState([0x01, 0x01]); } /** * Turns off the ceiling light. * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the light is OFF (false). */ async turnOff() { return this.setState([0x01, 0x02]); } /** * Sets the brightness of the ceiling light. * @param {number} brightness - The brightness percentage (0-100). * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the operation was successful. */ async setBrightness(brightness) { if (typeof brightness !== 'number' || brightness < 0 || brightness > 100) { throw new TypeError(`Invalid brightness value: ${brightness}`); } return this.setState([0x02, 0x14, brightness]); } /** * Sets the color temperature of the ceiling light. * @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) { if (typeof color_temperature !== 'number' || color_temperature < 0 || color_temperature > 100) { throw new TypeError(`Invalid color temperature value: ${color_temperature}`); } return this.setState([0x02, 0x17, color_temperature]); } /** * Sets the RGB color of the ceiling light. * @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) { if (typeof brightness !== 'number' || brightness < 0 || brightness > 100 || typeof red !== 'number' || red < 0 || red > 255 || typeof green !== 'number' || green < 0 || green > 255 || typeof blue !== 'number' || blue < 0 || blue > 255) { throw new TypeError('Invalid RGB or brightness values'); } return this.setState([0x02, 0x12, brightness, red, green, blue]); } /** * Sends a command to the ceiling light. * @param {number[]} bytes - The command bytes. * @returns {Promise<boolean>} - Resolves with a boolean indicating whether the operation was successful. */ async operateCeilingLight(bytes) { const reqBuf = Buffer.from(bytes); const resBuf = await this.command(reqBuf); if (resBuf.length === 2) { const code = resBuf.readUInt8(1); if (code === 0x00 || code === 0x80) { return code === 0x80; } throw new Error(`The device returned an error: 0x${resBuf.toString('hex')}`); } throw new Error(`Expecting a 2-byte response, got instead: 0x${resBuf.toString('hex')}`); } } /** * Class representing a WoContact device. * @see https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/latest/devicetypes/contactsensor.md */ export class WoContact extends SwitchbotDevice { /** * Parses the service data for WoContact. * @param {Buffer} serviceData - The service data buffer. * @param {Function} emitLog - The function to emit log messages. * @returns {Promise<contactSensorServiceData | null>} - Parsed service data or null if invalid. */ static async parseServiceData(serviceData, emitLog) { if (serviceData.length !== 9) { emitLog('debugerror', `[parseServiceDataForWoContact] Buffer length ${serviceData.length} !== 9!`); return null; } const [byte1, byte2, byte3, , , , , , byte8] = serviceData; const hallState = (byte3 >> 1) & 0b00000011; const tested = Boolean(byte1 & 0b10000000); const movement = Boolean(byte1 & 0b01000000); const battery = byte2 & 0b01111111; const contact_open = Boolean(byte3 & 0b00000010); const contact_timeout = Boolean(byte3 & 0b00000100); const lightLevel = byte3 & 0b00000001 ? 'bright' : 'dark'; const button_count = byte8 & 0b00001111; const doorState = hallState === 0 ? 'close' : hallState === 1 ? 'open' : 'timeout no closed'; const data = { model: SwitchBotBLEModel.ContactSensor, modelName: SwitchBotBLEModelName.ContactSensor, modelFriendlyName: SwitchBotBLEModelFriendlyName.ContactSensor, movement, tested, battery, contact_open, contact_timeout, lightLevel, button_count, doorState, }; return data; } constructor(peripheral, noble) { super(peripheral, noble); } } /** * Class representing a WoCurtain device. * @see https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/latest/devicetypes/curtain.md * @see https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/latest/devicetypes/curtain3.md */ export class WoCurtain extends SwitchbotDevice { /** * Parses the service data for WoCurtain. * @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 position. * @returns {Promise<curtainServiceData | curtain3ServiceData | null>} - Parsed service data or null if invalid. */ static async parseServiceData(serviceData, manufacturerData, emitLog, reverse = false) { if (![5, 6].includes(serviceData.length)) { emitLog('debugerror', `[parseServiceDataForWoCurtain] Buffer length ${serviceData.length} !== 5 or 6!`); return null; } const byte1 = serviceData.readUInt8(1); const byte2 = serviceData.readUInt8(2); let deviceData; let batteryData = null; if (manufacturerData.length >= 13) { deviceData = manufacturerData.subarray(8, 11); batteryData = manufacturerData.readUInt8(12); } else if (manufacturerData.length >= 11) { deviceData = manufacturerData.subarray(8, 11); batteryData = byte2; } else { deviceData = serviceData.subarray(3, 6); batteryData = byte2; } const model = serviceData.subarray(0, 1).toString('utf8') ? SwitchBotBLEModel.Curtain : SwitchBotBLEModel.Curtain3; const calibration = Boolean(byte1 & 0b01000000);