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