node-switchbot
Version:
The node-switchbot is a Node.js module which allows you to control your Switchbot Devices through Bluetooth (BLE).
317 lines • 14.7 kB
JavaScript
import { EventEmitter } from 'node:events';
import { Advertising, LogLevel, SwitchBotBLEModel, SwitchbotDevice, WoBlindTilt, WoBulb, WoCeilingLight, WoContact, WoCurtain, WoHand, WoHub2, WoHumi, WoHumi2, WoIOSensorTH, WoKeypad, WoLeak, WoPlugMiniJP, WoPlugMiniUS, WoPresence, WoRelaySwitch1, WoRelaySwitch1PM, WoRemote, WoSensorTH, WoSensorTHPlus, WoSensorTHPro, WoSensorTHProCO2, WoSmartLock, WoSmartLockPro, WoStrip } from './device.js';
import { parameterChecker } from './parameter-checker.js';
import { DEFAULT_DISCOVERY_DURATION, PRIMARY_SERVICE_UUID_LIST } from './settings.js';
/**
* SwitchBotBLE class to interact with SwitchBot devices.
*/
export class SwitchBotBLE extends EventEmitter {
nobleInitialized;
noble;
ondiscover;
onadvertisement;
/**
* Constructor
*
* @param {Params} [params] - Optional parameters
*/
constructor(params) {
super();
this.nobleInitialized = this.initialize(params);
}
/**
* Emits a log event with the specified log level and message.
*
* @param level - The severity level of the log (e.g., 'info', 'warn', 'error').
* @param message - The log message to be emitted.
*/
/**
* Emits a log event with a defined LogLevel.
*/
log(level, message) {
// Emit log events asynchronously with level and message as separate args
setTimeout(() => this.emit('log', { level, message }), 0);
}
/**
* Initializes the noble object.
*
* @param {Params} [params] - Optional parameters
* @returns {Promise<void>} - Resolves when initialization is complete
*/
async initialize(params) {
try {
if (params && params.noble) {
this.noble = params.noble;
}
else {
this.noble = (await import('@stoprocent/noble')).default;
}
try {
await this.noble.waitForPoweredOnAsync();
this.log(LogLevel.DEBUG, 'Noble powered on');
}
catch (e) {
this.log(LogLevel.ERROR, `Failed waiting for powered on: ${JSON.stringify(e.message ?? e)}`);
}
}
catch (e) {
this.log(LogLevel.ERROR, `Failed to import noble: ${JSON.stringify(e.message ?? e)}`);
}
}
/**
* Validates the parameters.
*
* @param {Params} params - The parameters to validate.
* @param {Record<string, unknown>} schema - The schema to validate against.
* @returns {Promise<void>} - Resolves if parameters are valid, otherwise throws an error.
*/
async validate(params, schema) {
const valid = parameterChecker.check(params, schema, false);
if (!valid) {
this.log(LogLevel.ERROR, `parameterChecker: ${JSON.stringify(parameterChecker.error.message)}`);
throw new Error(parameterChecker.error.message);
}
}
/**
* Discovers Switchbot devices with enhanced error handling and logging.
* @param params The discovery parameters.
* @returns A Promise that resolves with an array of discovered Switchbot devices.
*/
async discover(params = {}) {
await this.initialize(params);
await this.validate(params, {
duration: { required: false, type: 'integer', min: 1, max: 60000 },
model: { required: false, type: 'string', enum: Object.values(SwitchBotBLEModel) },
id: { required: false, type: 'string', min: 12, max: 17 },
quick: { required: false, type: 'boolean' },
});
if (!this.noble) {
throw new Error('Noble BLE library failed to initialize properly');
}
const p = {
duration: params.duration ?? DEFAULT_DISCOVERY_DURATION,
model: params.model ?? '',
id: params.id ?? '',
quick: !!params.quick,
};
this.log(LogLevel.DEBUG, `Starting discovery with parameters: ${JSON.stringify(p)}`);
const peripherals = {};
let timer;
let isDiscoveryActive = true;
const finishDiscovery = async () => {
if (!isDiscoveryActive) {
return Object.values(peripherals);
}
isDiscoveryActive = false;
if (timer) {
clearTimeout(timer);
}
if (this.noble) {
this.noble.removeAllListeners('discover');
try {
await this.noble.stopScanningAsync();
this.log(LogLevel.DEBUG, 'Successfully stopped scanning for SwitchBot BLE devices');
}
catch (e) {
this.log(LogLevel.ERROR, `Failed to stop scanning: ${JSON.stringify(e.message ?? e)}`);
}
}
const devices = Object.values(peripherals);
const deviceCount = devices.length;
this.log(deviceCount > 0 ? LogLevel.INFO : LogLevel.WARN, `Discovery completed. Found ${deviceCount} device${deviceCount !== 1 ? 's' : ''}`);
return devices;
};
return new Promise((resolve, reject) => {
this.noble.on('discover', async (peripheral) => {
try {
const device = await this.createDevice(peripheral, p.id, p.model);
if (!device) {
return;
}
if (peripherals[device.id]) {
this.log(LogLevel.DEBUG, `Device ${device.id} already discovered, skipping duplicate`);
return;
}
peripherals[device.id] = device;
this.log(LogLevel.DEBUG, `Discovered device: ${device.friendlyName} (${device.id}) at ${device.address}`);
if (this.ondiscover) {
try {
await this.ondiscover(device);
}
catch (e) {
this.log(LogLevel.ERROR, `Error in ondiscover callback: ${e.message ?? e}`);
}
}
if (p.quick) {
this.log(LogLevel.DEBUG, 'Quick discovery mode: stopping after first device found');
resolve(await finishDiscovery());
}
}
catch (e) {
this.log(LogLevel.ERROR, `Error processing discovered device: ${e.message ?? e}`);
}
});
// Start scanning with timeout handling
this.noble.startScanningAsync(PRIMARY_SERVICE_UUID_LIST, false)
.then(() => {
this.log(LogLevel.DEBUG, `Started scanning for ${p.duration}ms`);
timer = setTimeout(async () => {
const result = await finishDiscovery();
if (result.length === 0) {
reject(new Error(`No SwitchBot devices found after ${p.duration}ms discovery timeout`));
}
else {
resolve(result);
}
}, p.duration);
})
.catch((error) => {
this.log(LogLevel.ERROR, `Failed to start scanning: ${error.message ?? error}`);
reject(new Error(`Failed to start BLE scanning: ${error.message ?? error}`));
});
});
}
/**
* Creates a device object based on the peripheral, id, and model.
*
* @param {NobleTypes['peripheral']} peripheral - The peripheral object.
* @param {string} id - The device id.
* @param {string} model - The device model.
* @returns {Promise<SwitchbotDevice | null>} - The device object or null.
*/
async createDevice(peripheral, id, model) {
const ad = await Advertising.parse(peripheral, (level, message) => this.log(level, message));
if (ad && await this.filterAd(ad, id, model) && this.noble) {
switch (ad.serviceData.model) {
case SwitchBotBLEModel.Bot: return new WoHand(peripheral, this.noble);
case SwitchBotBLEModel.Curtain:
case SwitchBotBLEModel.Curtain3: return new WoCurtain(peripheral, this.noble);
case SwitchBotBLEModel.Humidifier: return new WoHumi(peripheral, this.noble);
case SwitchBotBLEModel.Humidifier2: return new WoHumi2(peripheral, this.noble);
case SwitchBotBLEModel.Meter: return new WoSensorTH(peripheral, this.noble);
case SwitchBotBLEModel.MeterPlus: return new WoSensorTHPlus(peripheral, this.noble);
case SwitchBotBLEModel.MeterPro: return new WoSensorTHPro(peripheral, this.noble);
case SwitchBotBLEModel.MeterProCO2: return new WoSensorTHProCO2(peripheral, this.noble);
case SwitchBotBLEModel.Hub2: return new WoHub2(peripheral, this.noble);
case SwitchBotBLEModel.OutdoorMeter: return new WoIOSensorTH(peripheral, this.noble);
case SwitchBotBLEModel.MotionSensor: return new WoPresence(peripheral, this.noble);
case SwitchBotBLEModel.ContactSensor: return new WoContact(peripheral, this.noble);
case SwitchBotBLEModel.Remote: return new WoRemote(peripheral, this.noble);
case SwitchBotBLEModel.ColorBulb: return new WoBulb(peripheral, this.noble);
case SwitchBotBLEModel.CeilingLight:
case SwitchBotBLEModel.CeilingLightPro: return new WoCeilingLight(peripheral, this.noble);
case SwitchBotBLEModel.StripLight: return new WoStrip(peripheral, this.noble);
case SwitchBotBLEModel.Leak: return new WoLeak(peripheral, this.noble);
case SwitchBotBLEModel.PlugMiniUS: return new WoPlugMiniUS(peripheral, this.noble);
case SwitchBotBLEModel.PlugMiniJP: return new WoPlugMiniJP(peripheral, this.noble);
case SwitchBotBLEModel.Lock: return new WoSmartLock(peripheral, this.noble);
case SwitchBotBLEModel.LockPro: return new WoSmartLockPro(peripheral, this.noble);
case SwitchBotBLEModel.BlindTilt: return new WoBlindTilt(peripheral, this.noble);
case SwitchBotBLEModel.Keypad: return new WoKeypad(peripheral, this.noble);
case SwitchBotBLEModel.RelaySwitch1: return new WoRelaySwitch1(peripheral, this.noble);
case SwitchBotBLEModel.RelaySwitch1PM: return new WoRelaySwitch1PM(peripheral, this.noble);
default: return new SwitchbotDevice(peripheral, this.noble);
}
}
return null;
}
/**
* Filters advertising data based on id and model.
*
* @param {Ad} ad - The advertising data.
* @param {string} id - The device id.
* @param {string} model - The device model.
* @returns {Promise<boolean>} - True if the advertising data matches the id and model, false otherwise.
*/
async filterAd(ad, id, model) {
if (!ad) {
return false;
}
if (id && ad.address.toLowerCase().replace(/[^a-z0-9]/g, '') !== id.toLowerCase().replace(/:/g, '')) {
return false;
}
if (model && ad.serviceData.model !== model) {
return false;
}
return true;
}
/**
* Starts scanning for SwitchBot devices.
*
* @param {Params} [params] - Optional parameters.
* @returns {Promise<void>} - Resolves when scanning starts successfully.
*/
async startScan(params = {}) {
await this.nobleInitialized;
await this.validate(params, {
model: { required: false, type: 'string', enum: Object.values(SwitchBotBLEModel) },
id: { required: false, type: 'string', min: 12, max: 17 },
});
if (!this.noble) {
throw new Error('noble object failed to initialize');
}
const p = { model: params.model || '', id: params.id || '' };
this.noble.removeAllListeners('discover');
this.noble.on('discover', async (peripheral) => {
try {
const ad = await Advertising.parse(peripheral, (level, message) => this.log(level, message));
this.log(LogLevel.DEBUG, `Advertisement: ${JSON.stringify(ad)}`);
this.log(LogLevel.DEBUG, `Filter ID: ${p.id}`);
this.log(LogLevel.DEBUG, `Filter Model: ${p.model}`);
if (ad && await this.filterAd(ad, p.id, p.model)) {
this.log(LogLevel.DEBUG, `Advertisement passed filter: ${JSON.stringify(ad)}`);
if (this.onadvertisement) {
try {
await this.onadvertisement(ad);
}
catch (e) {
this.log(LogLevel.ERROR, `Error in onadvertisement callback: ${e.message ?? e}`);
}
}
}
}
catch (e) {
this.log(LogLevel.ERROR, `Error parsing advertisement: ${e.message ?? e}`);
}
});
try {
await this.noble.startScanningAsync(PRIMARY_SERVICE_UUID_LIST, true);
this.log(LogLevel.DEBUG, 'Started Scanning for SwitchBot BLE devices.');
}
catch (e) {
this.log(LogLevel.ERROR, `startScanningAsync error: ${JSON.stringify(e.message ?? e)}`);
}
}
/**
* Stops scanning for SwitchBot devices.
*
* @returns {Promise<void>} - Resolves when scanning stops successfully.
*/
async stopScan() {
if (!this.noble) {
return;
}
this.noble.removeAllListeners('discover');
try {
await this.noble.stopScanningAsync();
this.log(LogLevel.DEBUG, 'Stopped Scanning for SwitchBot BLE devices.');
}
catch (e) {
this.log(LogLevel.ERROR, `stopScanningAsync error: ${JSON.stringify(e.message ?? e)}`);
}
}
/**
* Waits for the specified time.
*
* @param {number} msec - The time to wait in milliseconds.
* @returns {Promise<void>} - Resolves after the specified time.
*/
async wait(msec) {
if (typeof msec !== 'number' || msec < 0) {
throw new Error('Invalid parameter: msec must be a non-negative integer.');
}
return new Promise(resolve => setTimeout(resolve, msec));
}
}
export { LogLevel, SwitchbotDevice };
//# sourceMappingURL=switchbot-ble.js.map