diff --git a/README.md b/README.md index cfb4dccd..a46d80c2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ </span> The node-switchbot is a Node.js module which allows you to move your [Switchbot (Bot)'s](https://www.switch-bot.com/bot) arm -and [Switchbot Curtain](https://www.switch-bot.com/products/switchbot-curtain), +and [Switchbot Curtain](https://www.switch-bot.com/products/switchbot-curtain), operate your [Switchbot Lock](https://www.switch-bot.com/products/switchbot-lock), also monitor the temperature/humidity from [SwitchBot Thermometer & Hygrometer (Meter)](https://www.switch-bot.com/meter) as well as the status from [SwitchBot Motion Sensor](https://www.switch-bot.com/products/motion-sensor) and [SwitchBot Contact Sensor](https://www.switch-bot.com/products/contact-sensor) @@ -43,21 +43,26 @@ But some functionalities of this module were developed through trial and error. - [`disconnect()` method](#disconnect-method) - [`onconnect` event handler](#onconnect-event-handler) - [`ondisconnect` event handler](#ondisconnect-event-handler) - - [`WoHand` object](#switchbotdevicewohand-object) + - [`WoHand` object](#wohand-object) - [`press()` method](#press-method) - [`turnOn()` method](#turnon-method) - [`turnOff()` method](#turnoff-method) - [`down()` method](#down-method) - [`up()` method](#up-method) - - [`WoCurtain` object](#switchbotdevicewocurtain-object) + - [`WoCurtain` object](#wocurtain-object) - [`open()` method](#open-method) - [`close()` method](#close-method) - [`pause()` method](#pause-method) - [`runToPos()` method](#runtopos-method) - - [`WoPlugMini` object](#switchbotdevicewoplugmini-object) + - [`WoPlugMini` object](#woplugmini-object) - [`turnOn()` method](#turnon-method) - [`turnOff()` method](#turnoff-method) - [`toggle()` method](#toggle-method) + - [`WoSmartLock` object](#wosmartlock-object) + - [`lock()` method](#lock-method) + - [`unlock()` method](#unlock-method) + - [`unlock_no_unlatch()` method](#unlock_no_unlatch-method) + - [`info()` method](#info-method) - [Advertisement data](#advertisement-data) - [Bot (WoHand)](#bot-wohand) - [Meter (WoSensorTH)](#meter-wosensorth) @@ -876,6 +881,49 @@ If no connection is established with the device, this method automatically estab --- +--- +## `WoSmartLock` object + +The `WoSmartLock ` object represents a SmartLock, which is created through the discovery process triggered by the [`Switchbot.discover()`](#Switchbot-discover-method) method. + +Actually, the `WoSmartLock ` is an object inherited from the [`SwitchbotDevice`](#SwitchbotDevice-object). You can use not only the method described in this section but also the properties and methods implemented in the [`SwitchbotDevice`](#SwitchbotDevice-object) object. + +### `setKey()` method + +The `setKey()` method initialises the key information required for encrypted communication with the SmartLock + +This must be set before any control commands are sent to the device. To obtain the key information you will need to use an external tool - see [`pySwitchbot`](https://github.com/Danielhiversen/pySwitchbot/tree/master?tab=readme-ov-file#obtaining-locks-encryption-key) project for an example script. + +| Property | Type | Description | +| :--------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------- | +| `keyId` | String | unique2 character ID for the key. (e.g., `"ff"`) returned from the SwitchBot api for your device | +| `encryptionKey` | String | the unique encryption key returned from the SwitchBot api for your device | + +### `lock()` method + +The `lock()` method sends a lock command to the SmartLock. This method returns a `Promise` object. A `boolean` value indicating whether the SmartLock is locked (`true`), is passed to the `resolve()` method of the Promise. + +If no connection is established with the device, this method automatically establishes a connection with the device, then finally closes the connection. You don't have to call the [`connect()`](#SwitchbotDevice-connect-method) method in advance. + +### `unlock()` method + +The `unlock()` method sends an unlock command to the SmartLock. This method returns a `Promise` object. A `boolean` value indicating whether the SmartLock is locked (`false`), is passed to the `resolve()` method of the Promise. + +If no connection is established with the device, this method automatically establishes a connection with the device, then finally closes the connection. You don't have to call the [`connect()`](#SwitchbotDevice-connect-method) method in advance. + +### `unlockNoUnlatch()` method + +The `unlockNoUnlatch()` method sends a partial unlock command to the SmartLock, unlocking without the full unlatch. + +If no connection is established with the device, this method automatically establishes a connection with the device, then finally closes the connection. You don't have to call the [`connect()`](#SwitchbotDevice-connect-method) method in advance. + +### `info()` method + +The `info()` method retreieves state information from the SmartLock, This method returns a `Promise` object. An `object` value indicating with the state infor, is passed to the `resolve()` method of the Promise. + +If no connection is established with the device, this method automatically establishes a connection with the device, then finally closes the connection. You don't have to call the [`connect()`](#SwitchbotDevice-connect-method) method in advance. + + ## Advertisement data After the [`startScan()`](#startscan-method) method is invoked, the [`onadvertisement`](#Switchbot-onadvertisement-event-handler) event handler will be called whenever an advertising packet comes from the switchbot devices. An object containing the properties as follows will be passed to the event handler: diff --git a/src/device.ts b/src/device.ts index 112a7192..d760ece3 100644 --- a/src/device.ts +++ b/src/device.ts @@ -4,7 +4,7 @@ */ import { Characteristic, Peripheral, Service } from '@abandonware/noble'; import { ParameterChecker } from './parameter-checker.js'; -import { Advertising } from './advertising.js'; +import { Advertising, Ad } from './advertising.js'; type Chars = { write: Characteristic | null, @@ -47,11 +47,12 @@ export class SwitchbotDevice { this._chars = null; // Save the device information - const ad = Advertising.parse(peripheral); - this._id = ad?.id; - this._address = ad?.address; - this._model = ad?.serviceData.model; - this._modelName = ad?.serviceData.modelName; + const ad: Ad = Advertising.parse(peripheral); + this._id = ad ? ad.id : null; + this._address = ad ? ad.address : null; + this._model = ad ? ad.serviceData.model : null; + this._modelName = ad ? ad.serviceData.modelName : null; + this._was_connected_explicitly = false; this._connected = false; @@ -286,7 +287,7 @@ export class SwitchbotDevice { _subscribe() { return new Promise<void>((resolve, reject) => { - const char = this._chars?.notify; + const char = this._chars ? this._chars.notify : null; if (!char) { reject(new Error('No notify characteristic was found.')); return; @@ -306,7 +307,7 @@ export class SwitchbotDevice { _unsubscribe() { return new Promise<void>((resolve) => { - const char = this._chars?.notify; + const char = this._chars ? this._chars.notify : null; if (!char) { resolve(); return; @@ -380,7 +381,7 @@ export class SwitchbotDevice { let name = ''; this._connect() .then(() => { - if (!this._chars?.device) { + if (!this._chars || !this._chars.device) { // Some models of Bot don't seem to support this characteristic UUID throw new Error( 'The device does not support the characteristic UUID 0x' + @@ -434,7 +435,7 @@ export class SwitchbotDevice { const buf = Buffer.from(name, 'utf8'); this._connect() .then(() => { - if (!this._chars?.device) { + if (!this._chars || !this._chars.device) { // Some models of Bot don't seem to support this characteristic UUID throw new Error( 'The device does not support the characteristic UUID 0x' + @@ -470,7 +471,7 @@ export class SwitchbotDevice { this._connect() .then(() => { - if (!this._chars?.write) { + if (!this._chars || !this._chars.write) { return reject(new Error('No characteristics available.')); } return this._write(this._chars.write, req_buf); diff --git a/src/device/wosmartlock.ts b/src/device/wosmartlock.ts index 84ecbf96..1212438a 100644 --- a/src/device/wosmartlock.ts +++ b/src/device/wosmartlock.ts @@ -1,15 +1,66 @@ -/* Copyright(C) 2024, donavanbecker (https://github.com/donavanbecker). All rights reserved. - * +/* * wosmartlock.ts: Switchbot BLE API registration. + * adapted off the work done by [pySwitchbot](https://github.com/Danielhiversen/pySwitchbot) */ import { SwitchbotDevice } from '../device.js'; +import { Peripheral } from '@abandonware/noble'; +import * as Crypto from 'crypto'; export class WoSmartLock extends SwitchbotDevice { + _iv:any; + _key_id:string; + _encryption_key:any; + + static COMMAND_GET_CK_IV = '570f2103'; + static COMMAND_LOCK_INFO = '570f4f8101'; + static COMMAND_UNLOCK = '570f4e01011080'; + static COMMAND_UNLOCK_NO_UNLATCH = '570f4e010110a0'; + static COMMAND_LOCK = '570f4e01011000'; + + static Result = { + ERROR: 0x00, + SUCCESS: 0x01, + SUCCESS_LOW_BATTERY: 0x06, + }; + + static validateResponse(res: Buffer) { + if (res.length >= 3) { + switch (res.readUInt8(0)) { + case WoSmartLock.Result.SUCCESS: + return WoSmartLock.Result.SUCCESS; + case WoSmartLock.Result.SUCCESS_LOW_BATTERY: + return WoSmartLock.Result.SUCCESS_LOW_BATTERY; + } + } + return WoSmartLock.Result.ERROR; + } + + static getLockStatus(code: number) { + switch (code) { + case 0b0000000: + return 'LOCKED'; + case 0b0010000: + return 'UNLOCKED'; + case 0b0100000: + return 'LOCKING'; + case 0b0110000: + return 'UNLOCKING'; + case 0b1000000: + return 'LOCKING_STOP'; + case 0b1010000: + return 'UNLOCKING_STOP'; + case 0b1100000: //Only EU lock type + return 'NOT_FULLY_LOCKED'; + default: + return 'UNKNOWN'; + } + } + static parseServiceData(manufacturerData: Buffer, onlog: ((message: string) => void) | undefined) { - if (manufacturerData.length !== 6) { + if (manufacturerData.length !== 12) { if (onlog && typeof onlog === 'function') { onlog( - `[parseServiceDataForWoSmartLock] Buffer length ${manufacturerData.length} !== 6!`, + `[parseServiceDataForWoSmartLock] Buffer length ${manufacturerData.length} !== 12!`, ); } return null; @@ -18,40 +69,9 @@ export class WoSmartLock extends SwitchbotDevice { const byte7 = manufacturerData.readUInt8(7); const byte8 = manufacturerData.readUInt8(8); - function getStatus(code: number): string { - switch (code) { - case LockStatus.LOCKED: - return 'LOCKED'; - case LockStatus.UNLOCKED: - return 'UNLOCKED'; - case LockStatus.LOCKING: - return 'LOCKING'; - case LockStatus.UNLOCKING: - return 'UNLOCKING'; - case LockStatus.LOCKING_STOP: - return 'LOCKING_STOP'; - case LockStatus.UNLOCKING_STOP: - return 'UNLOCKING_STOP'; - case LockStatus.NOT_FULLY_LOCKED: - return 'NOT_FULLY_LOCKED'; - default: - return 'UNKNOWN'; - } - } - - const LockStatus = { - LOCKED: 0b0000000, - UNLOCKED: 0b0010000, - LOCKING: 0b0100000, - UNLOCKING: 0b0110000, - LOCKING_STOP: 0b1000000, - UNLOCKING_STOP: 0b1010000, - NOT_FULLY_LOCKED: 0b1100000, //Only EU lock type - }; - const battery = byte2 & 0b01111111; // % const calibration = byte7 & 0b10000000 ? true : false; - const status = getStatus(byte7 & 0b01110000); + const status = WoSmartLock.getLockStatus(byte7 & 0b01110000); const update_from_secondary_lock = byte7 & 0b00001000 ? true : false; const door_open = byte7 & 0b00000100 ? true : false; const double_lock_mode = byte8 & 0b10000000 ? true : false; @@ -76,5 +96,185 @@ export class WoSmartLock extends SwitchbotDevice { return data; } + constructor(peripheral: Peripheral, noble: any) { + super(peripheral, noble); + this._iv = null; + this._key_id = ''; + this._encryption_key = null; + } + + /* ------------------------------------------------------------------ + * setKey() + * - initialise the encryption key info for valid lock communication, this currently must be retrived externally + * + * [Arguments] + * - keyId, encryptionKey + * + * [Return value] + * - void + * ---------------------------------------------------------------- */ + setKey(keyId: string, encryptionKey: string) { + this._iv = null; + this._key_id = keyId; + this._encryption_key = Buffer.from(encryptionKey, 'hex'); + } + + /* ------------------------------------------------------------------ + * unlock() + * - Unlock the Smart Lock + * + * [Arguments] + * - none + * + * [Return value] + * - Promise object + * WoSmartLock.LockResult will be passed to the `resolve()`. + * ---------------------------------------------------------------- */ + unlock() { + return new Promise<number>((resolve, reject) => { + this._operateLock(WoSmartLock.COMMAND_UNLOCK) + .then((resBuf) => { + resolve(WoSmartLock.validateResponse(resBuf)); + }).catch((error) => { + reject(error); + }); + }); + } + + /* ------------------------------------------------------------------ + * unlockNoUnlatch() + * - Unlock the Smart Lock without unlatching door + * + * [Arguments] + * - none + * + * [Return value] + * - Promise object + * WoSmartLock.LockResult will be passed to the `resolve()`. + * ---------------------------------------------------------------- */ + unlockNoUnlatch() { + return new Promise<number>((resolve, reject) => { + this._operateLock(WoSmartLock.COMMAND_UNLOCK_NO_UNLATCH) + .then((resBuf) => { + resolve(WoSmartLock.validateResponse(resBuf)); + }).catch((error) => { + reject(error); + }); + }); + } + + /* ------------------------------------------------------------------ + * lock() + * - Lock the Smart Lock + * + * [Arguments] + * - none + * + * [Return value] + * - Promise object + * WoSmartLock.LockResult will be passed to the `resolve()`. + * ---------------------------------------------------------------- */ + lock() { + return new Promise<number>((resolve, reject) => { + this._operateLock(WoSmartLock.COMMAND_LOCK) + .then((resBuf) => { + resolve(WoSmartLock.validateResponse(resBuf)); + }).catch((error) => { + reject(error); + }); + }); + } + + /* ------------------------------------------------------------------ + * info() + * - Get general state info from the Smart Lock + * + * [Arguments] + * - none + * + * [Return value] + * - Promise object + * state object will be passed to the `resolve()` + * ---------------------------------------------------------------- */ + info() { + return new Promise((resolve, reject) => { + this._operateLock(WoSmartLock.COMMAND_LOCK_INFO) + .then(resBuf => { + const data ={ + 'calibration': Boolean(resBuf[1] & 0b10000000), + 'status': WoSmartLock.getLockStatus((resBuf[1] & 0b01110000)), + 'door_open': Boolean(resBuf[1] & 0b00000100), + 'unclosed_alarm': Boolean(resBuf[2] & 0b00100000), + 'unlocked_alarm': Boolean(resBuf[2] & 0b00010000), + }; + resolve(data); + }).catch((error) => { + reject(error); + }); + }); + } + + _encrypt(str:string) { + const cipher = Crypto.createCipheriv('aes-128-ctr', this._encryption_key, this._iv); + return Buffer.concat([cipher.update(str, 'hex'), cipher.final()]).toString('hex'); + } + + _decrypt(data:Buffer) { + const decipher = Crypto.createDecipheriv('aes-128-ctr', this._encryption_key, this._iv); + return Buffer.concat([decipher.update(data), decipher.final()]); + } + + async _getIv() { + if (this._iv === null) { + const res:Buffer = await this._operateLock(WoSmartLock.COMMAND_GET_CK_IV + this._key_id, false); + this._iv = res.subarray(4); + } + return this._iv; + } + + async _encryptedCommand(key: string) { + const iv = await this._getIv(); + const req = Buffer.from( + key.substring(0, 2) + this._key_id + Buffer.from(iv.subarray(0, 2)).toString('hex') + this._encrypt(key.substring(2)) + , 'hex'); + + const bytes: unknown = await this._command(req); + const buf = Buffer.from(bytes as Uint8Array); + const code = WoSmartLock.validateResponse(buf); + + if (code !== WoSmartLock.Result.ERROR) { + return Buffer.concat([buf.subarray(0, 1), this._decrypt(buf.subarray(4))]); + } else { + throw ( + new Error('The device returned an error: 0x' + buf.toString('hex'), + ) + ); + } + } + + _operateLock(key: string, encrypt: boolean = true) { + //encrypted command + if (encrypt) { + return this._encryptedCommand(key); + } + + //unencypted command + return new Promise<any>((resolve, reject) => { + const req = Buffer.from(key.substring(0, 2) + '000000' + key.substring(2), 'hex'); + + this._command(req).then(bytes => { + const buf = Buffer.from(bytes as Uint8Array); + const code = WoSmartLock.validateResponse(buf); + + if (code === WoSmartLock.Result.ERROR) { + reject(new Error('The device returned an error: 0x' + buf.toString('hex'))); + } else { + resolve(buf); + } + }).catch(error => { + reject(error); + }); + }); + } } diff --git a/src/switchbot.ts b/src/switchbot.ts index 5c779f58..8e8bbe51 100644 --- a/src/switchbot.ts +++ b/src/switchbot.ts @@ -17,6 +17,7 @@ import { WoHumi } from './device/wohumi.js'; import { WoPlugMini } from './device/woplugmini.js'; import { WoBulb } from './device/wobulb.js'; import { WoStrip } from './device/wostrip.js'; +import { WoSmartLock } from './device/wosmartlock.js'; import { Ad } from './advertising.js'; import { Peripheral } from '@abandonware/noble'; @@ -164,25 +165,29 @@ export class SwitchBot { // Initialize the noble object this._init() .then(() => { + if (this.noble == null) { + return reject(new Error('noble failed to initialize')); + } const peripherals: Record<string, SwitchbotDevice> = {}; let timer: NodeJS.Timeout = setTimeout(() => { }, 0); const finishDiscovery = () => { if (timer) { clearTimeout(timer); } - this.noble?.removeAllListeners('discover'); - this.noble?.stopScanning(); + + this.noble.removeAllListeners('discover'); + this.noble.stopScanning(); + const device_list: SwitchbotDevice[] = []; for (const addr in peripherals) { device_list.push(peripherals[addr]); } - if (device_list.length) { - resolve(device_list); - } + + resolve(device_list); }; // Set a handler for the 'discover' event - this.noble?.on('discover', (peripheral: Peripheral) => { + this.noble.on('discover', (peripheral: Peripheral) => { const device = this.getDeviceObject(peripheral, p.id, p.model) as SwitchbotDevice; if (!device) { return; @@ -199,9 +204,8 @@ export class SwitchBot { return; } }); - // Start scanning - this.noble?.startScanning( + this.noble.startScanning( this.PRIMARY_SERVICE_UUID_LIST, false, (error: Error) => { @@ -230,7 +234,7 @@ export class SwitchBot { resolve(); return; } - this.noble?.once('stateChange', (state: any) => { + this.noble.once('stateChange', (state: any) => { switch (state) { case 'unsupported': case 'unauthorized': @@ -298,7 +302,7 @@ export class SwitchBot { device = new WoPlugMini(peripheral, this.noble); break; case 'o': - //device = new SwitchbotDeviceWoSmartLock(peripheral, this.noble); + device = new WoSmartLock(peripheral, this.noble); break; case 'i': device = new WoSensorTH(peripheral, this.noble); @@ -435,6 +439,9 @@ export class SwitchBot { // Initialize the noble object this._init() .then(() => { + if (this.noble == null) { + return reject(new Error('noble object failed to initialize')); + } // Determine the values of the parameters const p = { model: params.model || '', @@ -442,7 +449,7 @@ export class SwitchBot { }; // Set a handler for the 'discover' event - this.noble?.on('discover', (peripheral: Peripheral) => { + this.noble.on('discover', (peripheral: Peripheral) => { const ad = Advertising.parse(peripheral, this.onlog); if (this.filterAdvertising(ad, p.id, p.model)) { if ( @@ -455,7 +462,7 @@ export class SwitchBot { }); // Start scanning - this.noble?.startScanning( + this.noble.startScanning( this.PRIMARY_SERVICE_UUID_LIST, true, (error: Error) => { @@ -485,8 +492,10 @@ export class SwitchBot { * - none * ---------------------------------------------------------------- */ stopScan() { - this.noble?.removeAllListeners('discover'); - this.noble?.stopScanning(); + if (this.noble == null) return; + + this.noble.removeAllListeners('discover'); + this.noble.stopScanning(); } /* ------------------------------------------------------------------