UNPKG

nubli

Version:
544 lines (543 loc) 23.3 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const events_1 = __importDefault(require("events")); const smartLockPairer_1 = require("./smartLockPairer"); const nukiConfig_1 = require("./nukiConfig"); const crc_1 = require("crc"); const states_1 = require("./states"); const libsodium_wrappers_1 = __importDefault(require("libsodium-wrappers")); const errorHandler_1 = require("./errorHandler"); const KeyTurnerStatesCommand_1 = require("./smartLockCommands/KeyTurnerStatesCommand"); const LockActionCommand_1 = require("./smartLockCommands/LockActionCommand"); const ChallengeCommand_1 = require("./smartLockCommands/ChallengeCommand"); const RequestConfigCommand_1 = require("./smartLockCommands/RequestConfigCommand"); const RequestAdvancedConfigCommand_1 = require("./smartLockCommands/RequestAdvancedConfigCommand"); const RequestAuthorizationsCommand_1 = require("./smartLockCommands/RequestAuthorizationsCommand"); class SmartLock extends events_1.default.EventEmitter { constructor(nubli, device) { super(); this.config = null; this.state = states_1.GeneralState.IDLE; this.partialPayload = new Buffer(0); this.currentCommand = null; this.stateChanged = null; this.lastManufacturerDataReceived = new Date(); this._stale = false; this.nubli = nubli; this.device = device; this.nukiPairingCharacteristic = null; this.nukiServiceCharacteristic = null; this.nukiUserCharacteristic = null; this.device.on("disconnect", () => __awaiter(this, void 0, void 0, function* () { this.debug("disconnected"); this.emit("disconnected"); this.nukiPairingCharacteristic = null; this.nukiServiceCharacteristic = null; this.nukiUserCharacteristic = null; if (this.currentCommand && !this.currentCommand.complete) { this.debug("Unexpected disconnect during command execution."); this.currentCommand.sendFailure(); this.resetCommand(); } })); // Check if the Smart Lock is stale setInterval(() => { if (this.nubli.scanning && !this._stale && (new Date().getTime() - this.lastManufacturerDataReceived.getTime()) / 1000 > 60) { this.debug("No Advertisement received from Smart Lock within 60 seconds - Marking Smart Lock as stale."); this._stale = true; this.emit("stale"); } }, 60 * 1000); } updateManufacturerData(data) { // See: https://developer.nuki.io/t/bluetooth-specification-questions/1109/3 if (data.length == 25) { let type = data.readUInt8(2); let dataLength = data.readUInt8(3); // 0x02 == iBeacon if (type == 2 && dataLength == 21) { let serviceUuid = data.slice(4, 20).toString('hex'); if (serviceUuid == SmartLock.NUKI_SERVICE_UUID) { let smartLockId = data.slice(20, 24).toString('hex').toUpperCase(); let rssi = data.readInt8(24); this.debug(rssi); this.lastManufacturerDataReceived = new Date(); if (this._stale) { this._stale = false; this.debug("Received advertisements again - Marking Smart Lock as recovered."); this.emit("staleRecovered"); } // Smart Lock sets rssi to -59 if an entry to the activity log has been added. // Once the bridge has read the new state the rssi value will be set back to -60. if (rssi == -59) { if (!this.stateChanged || (new Date().getTime() - this.stateChanged.getTime()) / 1000 > 60) { this.stateChanged = new Date(); this.emit("activityLogChanged"); } } else { this.stateChanged = null; } } } } } connect() { return __awaiter(this, void 0, void 0, function* () { return new Promise((resolve, reject) => { if (!this.device.connectable) { reject("Device is not connectable."); } else { if (this.isConnected()) { resolve(); return; } this.device.connect((error) => __awaiter(this, void 0, void 0, function* () { if (error) { reject(error); } else { yield this.discoverServicesAndCharacteristics(); yield this.populateCharacteristics(); yield this.setupUSDIOListener(); this.debug("connected"); this.emit("connected"); resolve(); } })); } }); }); } disconnect() { return __awaiter(this, void 0, void 0, function* () { return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { yield this.removeUSDIOListener(); this.device.disconnect((error) => __awaiter(this, void 0, void 0, function* () { if (error) { reject(error); } else { resolve(); } })); })); }); } isConnected() { return this.device.state == "connected"; } configExists(path) { if (path === undefined) { path = this.nubli.configPath; } return nukiConfig_1.NukiConfig.configExists(this.device.uuid, path); } readConfig(path) { return __awaiter(this, void 0, void 0, function* () { if (path === undefined) { path = this.nubli.configPath; } this.config = yield nukiConfig_1.NukiConfig.readConfig(this.device.uuid, path); }); } saveConfig(path) { return __awaiter(this, void 0, void 0, function* () { return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { if (path === undefined) { path = this.nubli.configPath; } if (!this.config) { reject(); } else { yield this.config.save(path); resolve(); } })); }); } pair(asBridge = true) { return __awaiter(this, void 0, void 0, function* () { return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { if (!this.isConnected()) { reject("Device is not connected"); return; } if (this.paired) { resolve(); return; } this.validateCharacteristics(); this.debug("All characteristics found. Trying to pair"); let nukiConfig; try { yield nukiConfig_1.NukiConfig.configExists(this.device.uuid, this.nubli.configPath); nukiConfig = yield nukiConfig_1.NukiConfig.readConfig(this.device.uuid, this.nubli.configPath); } catch (err) { nukiConfig = new nukiConfig_1.NukiConfig(this.device.uuid, false); } let pairer = new smartLockPairer_1.SmartLockPairer(this.nukiPairingCharacteristic, nukiConfig, asBridge); pairer.pair() .then((nukiConfig) => { this.config = nukiConfig; resolve(); }) .catch((error) => { this.debug(error); reject(error); }); })); }); } discoverServicesAndCharacteristics() { return __awaiter(this, void 0, void 0, function* () { return new Promise((resolve, reject) => { this.device.discoverSomeServicesAndCharacteristics([SmartLock.NUKI_SERVICE_UUID, SmartLock.NUKI_PAIRING_SERVICE_UUID], [], (error, services) => { if (error) { reject(error); } else { resolve(); } }); }); }); } populateCharacteristics() { for (let service of this.device.services) { for (let characteristic of service.characteristics) { switch (characteristic.uuid) { case SmartLock.NUKI_PAIRING_GENERAL_DATA_IO_CHARACTERISTIC_UUID: this.nukiPairingCharacteristic = characteristic; break; case SmartLock.NUKI_SERVICE_GENERAL_DATA_IO_CHARACTERISTIC_UUID: this.nukiServiceCharacteristic = characteristic; break; case SmartLock.NUKI_USER_SPECIFIC_DATA_IO_CHARACTERISTIC_UUID: this.nukiUserCharacteristic = characteristic; break; } } } } validateCharacteristics() { if (!this.nukiPairingCharacteristic || !this.nukiServiceCharacteristic || !this.nukiUserCharacteristic) { throw new Error("The device is not a Smart Lock."); } } static appendCRC(data) { let crc = crc_1.crc16ccitt(data); let crcBuf = new Buffer(2); crcBuf.writeUInt16LE(crc, 0); return Buffer.concat([data, crcBuf]); } static verifyCRC(data) { let crc = data.readUInt16LE(data.length - 2); return crc == crc_1.crc16ccitt(data.slice(0, data.length - 2)); } validateCRC(data) { if (!SmartLock.verifyCRC(data)) { let errorMessage = errorHandler_1.ErrorHandler.errorToMessage(states_1.GeneralError.BAD_CRC); this.emit("error", errorMessage); return false; } return true; } get paired() { if (this.config == null) { return false; } return this.config.paired; } executeCommand(command) { return __awaiter(this, void 0, void 0, function* () { this.debug("Executing command"); return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { if (!this.isConnected()) { try { yield this.connect(); } catch (error) { this.state = states_1.GeneralState.IDLE; reject(error); return; } } this.validateCharacteristics(); if (command.requiresChallenge) { this.currentCommand = new ChallengeCommand_1.ChallengeCommand(); yield this.writeEncryptedData(this.currentCommand.requestData(this.config)); try { let response = yield this.waitForResponse(); command.challenge = response.data.challenge; } catch (error) { this.state = states_1.GeneralState.IDLE; reject(error); return; } } this.currentCommand = command; yield this.writeEncryptedData(this.currentCommand.requestData(this.config)); let response; try { response = yield this.waitForResponse(); } catch (error) { this.state = states_1.GeneralState.IDLE; reject(error); return; } this.state = states_1.GeneralState.IDLE; resolve(response); })); }); } waitForResponse() { return __awaiter(this, void 0, void 0, function* () { return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { this.currentCommand.callback = (response) => { if (response.success) { resolve(response); } else { reject(response); } }; })); }); } requestConfig() { return __awaiter(this, void 0, void 0, function* () { this.debug("Reading configuration"); return yield this.executeCommand(new RequestConfigCommand_1.RequestConfigCommand()); }); } requestAdvancedConfig() { return __awaiter(this, void 0, void 0, function* () { this.debug("Reading advanced configuration"); return yield this.executeCommand(new RequestAdvancedConfigCommand_1.RequestAdvancedConfigCommand()); }); } readLockState() { return __awaiter(this, void 0, void 0, function* () { this.debug("Reading lock state"); return yield this.executeCommand(new KeyTurnerStatesCommand_1.KeyTurnerStatesCommand()); }); } unlock(updateCallback) { return __awaiter(this, void 0, void 0, function* () { this.debug("Unlocking"); return yield this.executeCommand(new LockActionCommand_1.LockActionCommand(states_1.LockAction.UNLOCK, updateCallback)); }); } lock(updateCallback) { return __awaiter(this, void 0, void 0, function* () { this.debug("Locking"); return yield this.executeCommand(new LockActionCommand_1.LockActionCommand(states_1.LockAction.LOCK, updateCallback)); }); } unlatch(updateCallback) { return __awaiter(this, void 0, void 0, function* () { this.debug("Unlatching"); return yield this.executeCommand(new LockActionCommand_1.LockActionCommand(states_1.LockAction.UNLATCH, updateCallback)); }); } lockNGo(updateCallback) { return __awaiter(this, void 0, void 0, function* () { this.debug("Lock N Go"); return yield this.executeCommand(new LockActionCommand_1.LockActionCommand(states_1.LockAction.LOCK_N_GO, updateCallback)); }); } lockNGoUnlatch(updateCallback) { return __awaiter(this, void 0, void 0, function* () { this.debug("Lock N Go Unlatch"); return yield this.executeCommand(new LockActionCommand_1.LockActionCommand(states_1.LockAction.LOCK_N_GO_UNLATCH, updateCallback)); }); } requestAuthorizations(pin, offset, count) { return __awaiter(this, void 0, void 0, function* () { this.debug("Requesting authorizations"); return yield this.executeCommand(new RequestAuthorizationsCommand_1.RequestAuthorizationsCommand(pin, offset, count)); }); } setupUSDIOListener() { return __awaiter(this, void 0, void 0, function* () { return new Promise((resolve, reject) => { this.nukiUserCharacteristic.subscribe((error) => { if (error) { reject(error); return; } this.nukiUserCharacteristic.on('data', (data, isNotification) => this.usdioDataReceived(data, isNotification)); resolve(); }); }); }); } removeUSDIOListener() { return __awaiter(this, void 0, void 0, function* () { return new Promise((resolve, reject) => { this.nukiUserCharacteristic.removeListener('data', this.usdioDataReceived); if (this.isConnected()) { this.nukiUserCharacteristic.unsubscribe((error) => { if (error) { reject(error); } resolve(); }); } else { resolve(); } }); }); } static prepareCommand(command, data) { // We need 2 bytes for the command; let buffer = new Buffer(2); buffer.writeUInt16LE(command, 0); if (data === null || data === undefined) { data = new Buffer(0); } return Buffer.concat([buffer, data]); } writeEncryptedData(data) { return __awaiter(this, void 0, void 0, function* () { if (!this.config || !this.paired) { throw new Error("Encrypted commands can only be sent for already paired smart locks."); } // We need 4 bytes for the authorization id let authIdBuf = new Buffer(4); authIdBuf.writeUInt32LE(this.config.authorizationId, 0); data = Buffer.concat([authIdBuf, data]); let dataCrc = SmartLock.appendCRC(data); let nonce = SmartLock.generateNonce(); let encryptedData = libsodium_wrappers_1.default.crypto_secretbox_easy(dataCrc, nonce, this.config.credentials.sharedSecret); let lengthBuf = new Buffer(2); lengthBuf.writeUInt16LE(encryptedData.length, 0); let header = Buffer.concat([nonce, authIdBuf, lengthBuf]); let message = Buffer.concat([header, encryptedData]); return new Promise((resolve, reject) => { this.nukiUserCharacteristic.write(message, false, (error) => { if (error) { reject(error); } else { this.state = states_1.GeneralState.RECEIVING_DATA; resolve(); } }); }); }); } static generateNonce(size = 24) { return libsodium_wrappers_1.default.randombytes_buf(size); } usdioDataReceived(payload, isNotification) { if (!this.config || !this.paired) { throw new Error("Data can only be received for already paired smart locks."); } // We did not expect any data if (this.state != states_1.GeneralState.RECEIVING_DATA) { this.debug("We didn't expected any data but still got some :("); return; } payload = Buffer.concat([this.partialPayload, payload]); // In case we cannot read the message length in the first packet if (payload.length < 30) { this.partialPayload = payload; return; } let messageLength = payload.readUInt16LE(28); let encryptedMessage = payload.slice(30); if (encryptedMessage.length < messageLength) { this.partialPayload = payload; return; } else if (encryptedMessage.length > messageLength) { this.emit("error", "We received too much data."); this.resetCommand(); return; } // We have received the full message this.partialPayload = new Buffer(0); let nonce = payload.slice(0, 24); let authIdentifierUnencrypted = payload.readUInt32LE(24); if (authIdentifierUnencrypted != this.config.authorizationId) { this.emit("error", "Invalid authorization identifier"); this.resetCommand(); return; } let decryptedPayload; try { decryptedPayload = Buffer.from(libsodium_wrappers_1.default.crypto_secretbox_open_easy(encryptedMessage, nonce, this.config.credentials.sharedSecret)); } catch (err) { this.emit("error", "We could not decrypt the payload"); this.resetCommand(); return; } // Validate CRC if (!this.validateCRC(decryptedPayload)) return; let authIdentifierEncrypted = decryptedPayload.readUInt32LE(0); if (authIdentifierEncrypted != this.config.authorizationId) { this.emit("error", "Invalid authorization identifier in encrypted payload"); this.resetCommand(); return; } let commandIdentifier = decryptedPayload.readUInt16LE(4); let decryptedData = decryptedPayload.slice(6, decryptedPayload.length - 2); if (this.currentCommand) { if (commandIdentifier == states_1.Command.ERROR_REPORT) { let errorMessage = errorHandler_1.ErrorHandler.errorToMessage(decryptedData.readInt8(0)); this.currentCommand.sendFailure(errorMessage); this.resetCommand(); return; } this.currentCommand.handleData(commandIdentifier, decryptedData); if (this.currentCommand.complete) { this.currentCommand.sendResponse(); this.resetCommand(); } } else { this.emit("error", "We received a message and expected one but have no active command. Bug?"); this.resetCommand(); } } resetCommand() { this.currentCommand = null; this.partialPayload = new Buffer(0); } get uuid() { return this.device.uuid; } get stale() { return this._stale; } debug(message) { this.nubli.debug(this.device.uuid + ": " + message); } } SmartLock.NUKI_SERVICE_UUID = "a92ee200550111e4916c0800200c9a66"; SmartLock.NUKI_PAIRING_SERVICE_UUID = "a92ee100550111e4916c0800200c9a66"; SmartLock.NUKI_PAIRING_GENERAL_DATA_IO_CHARACTERISTIC_UUID = "a92ee101550111e4916c0800200c9a66"; SmartLock.NUKI_SERVICE_GENERAL_DATA_IO_CHARACTERISTIC_UUID = "a92ee201550111e4916c0800200c9a66"; SmartLock.NUKI_USER_SPECIFIC_DATA_IO_CHARACTERISTIC_UUID = "a92ee202550111e4916c0800200c9a66"; exports.SmartLock = SmartLock;