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