nubli
Version:
Nuki Bluetooth Library
544 lines (543 loc) • 23.3 kB
JavaScript
"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;