UNPKG

node-switchbot

Version:

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

302 lines 13 kB
import { EventEmitter } from 'node:events'; import { Advertising, 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 { ready; noble; ondiscover; onadvertisement; /** * Constructor * * @param {Params} [params] - Optional parameters */ constructor(params) { super(); this.ready = 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. */ async log(level, message) { this.emit('log', { level, message }); } /** * 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; } } catch (e) { this.log('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('error', `parameterChecker: ${JSON.stringify(parameterChecker.error.message)}`); throw new Error(parameterChecker.error.message); } } /** * Waits for the noble object to be powered on. * * @returns {Promise<void>} - Resolves when the noble object is powered on. */ async waitForPowerOn() { await this.ready; if (this.noble && this.noble._state === 'poweredOn') { return; } return new Promise((resolve, reject) => { this.noble?.once('stateChange', (state) => { switch (state) { case 'unsupported': case 'unauthorized': case 'poweredOff': reject(new Error(`Failed to initialize the Noble object: ${state}`)); break; case 'resetting': case 'unknown': reject(new Error(`Adapter is not ready: ${state}`)); break; case 'poweredOn': resolve(); break; default: reject(new Error(`Unknown state: ${state}`)); } }); }); } /** * Discovers Switchbot devices. * @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' }, }); await this.waitForPowerOn(); if (!this.noble) { throw new Error('noble failed to initialize'); } const p = { duration: params.duration ?? DEFAULT_DISCOVERY_DURATION, model: params.model ?? '', id: params.id ?? '', quick: !!params.quick, }; const peripherals = {}; let timer; const finishDiscovery = async () => { if (timer) { clearTimeout(timer); } if (this.noble) { this.noble.removeAllListeners('discover'); try { await this.noble.stopScanningAsync(); this.log('debug', 'Stopped Scanning for SwitchBot BLE devices.'); } catch (e) { this.log('error', `discover stopScanningAsync error: ${JSON.stringify(e.message ?? e)}`); } } const devices = Object.values(peripherals); if (devices.length === 0) { this.log('warn', 'No devices found during discovery.'); } return devices; }; return new Promise((resolve, reject) => { this.noble.on('discover', async (peripheral) => { const device = await this.createDevice(peripheral, p.id, p.model); if (!device) { return; } peripherals[device.id] = device; if (this.ondiscover) { this.ondiscover(device); } if (p.quick) { resolve(await finishDiscovery()); } }); this.noble.startScanningAsync(PRIMARY_SERVICE_UUID_LIST, false) .then(() => { timer = setTimeout(async () => { const result = await finishDiscovery(); if (result.length === 0) { reject(new Error('No devices found during discovery.')); } else { resolve(result); } }, p.duration); }) .catch(reject); }); } /** * 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, this.log.bind(this)); 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.ready; await this.validate(params, { model: { required: false, type: 'string', enum: Object.values(SwitchBotBLEModel) }, id: { required: false, type: 'string', min: 12, max: 17 }, }); await this.waitForPowerOn(); if (!this.noble) { throw new Error('noble object failed to initialize'); } const p = { model: params.model || '', id: params.id || '' }; this.noble.on('discover', async (peripheral) => { const ad = await Advertising.parse(peripheral, this.log.bind(this)); this.emit('debug', `Advertisement: ${ad}`); this.emit('debug', `Filter ID: ${p.id}`); this.emit('debug', `Filter Model: ${p.model}`); if (ad && await this.filterAd(ad, p.id, p.model)) { this.emit('debug', `Advertisement passed filter: ${ad}`); if (this.onadvertisement) { this.onadvertisement(ad); } } }); try { await this.noble.startScanningAsync(PRIMARY_SERVICE_UUID_LIST, true); this.log('debug', 'Started Scanning for SwitchBot BLE devices.'); } catch (e) { this.log('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('debug', 'Stopped Scanning for SwitchBot BLE devices.'); } catch (e) { this.log('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 { SwitchbotDevice }; //# sourceMappingURL=switchbot-ble.js.map