UNPKG

keyble

Version:

Command line tools and library for controlling eQ-3 eqiva Bluetooth smart locks

998 lines (906 loc) 39 kB
'use strict'; var Answer_With_Security_Message, Answer_Without_Security_Message, Close_Connection_Message, Command_Message, Connection_Info_Message, Connection_Request_Message, Event_Emitter, Extendable, Fragment_Ack_Message, Key_Ble, Message, Message_Fragment, Pairing_Request_Message, Status_Changed_Notification_Message, Status_Info_Message, Status_Request_Message, User_Info_Message, User_Name_Set_Message, arrays_are_equal, bit_is_set, buffer_to_byte_array, byte_array_formats, byte_array_to_hex_string, byte_array_to_integer, canonicalize_hex_string, compute_authentication_value, compute_nonce, concatenated_array, convert_to_byte_array, create_array_of_length, create_random_byte, create_random_byte_array, create_random_integer, crypt_data, debug_communication, debug_events, dictify_array, encrypt_aes_ecb, extract_byte, first_valid_value, generic_ceil, hex_string_to_byte_array, integer_to_byte_array, integer_to_zero_prefixed_hex_string, is_array, is_buffer, is_function, is_of_type, is_string, is_valid_value, key_card_data_pattern, key_card_data_regexp, message_type, message_types, message_types_by_id, mixin_factory, mixin_own, padded_array, parse_key_card_data, simble, split_into_chunks, state, string_to_utf8_byte_array, time_limit_promise, utf8_byte_array_to_string, xor_array; // Checks if <value> is an array. Returns true if it is an array, false otherwise is_array = function(value) { return Array.isArray(value); }; arrays_are_equal = function(array1, array2) { var i, index, ref; if (!(is_array(array1) && is_array(array2))) { return false; } if (array1 === array2) { return true; } if (array1.length !== array2.length) { return false; } for (index = i = 0, ref = array1.length; i < ref; index = i += 1) { if (array1[index] !== array2[index]) { return false; } } return true; }; // Returns true if the passed argument <value> is neither null nor undefined is_valid_value = function(value) { return (value !== void 0) && (value !== null); }; // Returns the first value in <values...> that is neither null nor undefined first_valid_value = function(...values) { var i, len, value; for (i = 0, len = values.length; i < len; i++) { value = values[i]; if (is_valid_value(value)) { return value; } } }; // Converts integer value <integer> into a zero-prefixed hexadecimal string of length <number_of_digits> integer_to_zero_prefixed_hex_string = function(integer, number_of_digits) { return ('0'.repeat(number_of_digits) + integer.toString(0x10)).slice(-number_of_digits); }; // Convert the byte array <byte_array> to a hexadecimal string. Every byte value is converted to a two-digit, zero padded hexadecimal string, prefixed with string <prefix_string> (default:""), suffixed with string <suffix_string> (default:""). All bytes are separated with string <separator_string> (default:" ") byte_array_to_hex_string = function(byte_array, separator_string, prefix_string, suffix_string) { var byte; separator_string = first_valid_value(separator_string, ' '); prefix_string = first_valid_value(prefix_string, ''); suffix_string = first_valid_value(suffix_string, ''); return ((function() { var i, len, results; results = []; for (i = 0, len = byte_array.length; i < len; i++) { byte = byte_array[i]; results.push(`${prefix_string}${integer_to_zero_prefixed_hex_string(byte, 2)}${suffix_string}`); } return results; })()).join(separator_string); }; byte_array_to_integer = function(byte_array, start_offset, end_offset) { var i, offset, ref, ref1, temp; start_offset = first_valid_value(start_offset, 0); end_offset = first_valid_value(end_offset, byte_array.length); temp = 0; for (offset = i = ref = start_offset, ref1 = end_offset; i < ref1; offset = i += 1) { if (offset < 0) { offset += byte_array.length; } temp = (temp * 0x100) + byte_array[offset]; } return temp; }; // Returns true if the bit with index <bit_index> is set in <value>, false otherwise bit_is_set = function(value, bit_index) { return (value & (1 << bit_index)) !== 0; }; // Creates and returns mixin function (<object>, <mixin_objects...>) -> that mixins all properties for which <key_filter_function>(<key>, <mixin_object>, <object>) returns true into <object> mixin_factory = function(key_filter_function) { return function(object, ...mixin_objects) { var i, key, len, mixin_object; for (i = 0, len = mixin_objects.length; i < len; i++) { mixin_object = mixin_objects[i]; if (mixin_object) { for (key in mixin_object) { if (key_filter_function(key, mixin_object, object)) { object[key] = mixin_object[key]; } } } } return object; }; }; // (<object>, <mixin_objects...>) -> Mixin all own properties of <mixin_objects...> into <object> mixin_own = mixin_factory(function(key, mixin_object) { return mixin_object.hasOwnProperty(key); }); // A prototype object providing the basic features of extendable classes Extendable = { extend: function(properties) { var extended; extended = Object.create(this); extended.__super__ = this; return mixin_own(extended, properties); }, create: function() { var instance; instance = Object.create(this); instance.__type__ = this; this.initialize.apply(instance, arguments); return instance; }, initialize: function() {} }; // An abstract prototype object for message types Message = Extendable.extend({ initialize: function(data) { if (is_array(data)) { this.data_bytes = data; } else { this.data_bytes = this.encode(data); } this.data = this.decode(); }, decode: function() { var data, key, ref, value_function; data = {}; ref = this.properties; for (key in ref) { value_function = ref[key]; data[key] = value_function.apply(this); } return data; }, encode: function() { return []; }, is_secure: function() { return bit_is_set(this.id, 7); }, properties: {} }); // Create a new message type with properties <properties> message_type = function(properties) { return Message.extend(properties); }; // This class represents "CLOSE_CONNECTION" messages; messages sent to the Smart Lock in order to close the connection // Java class "de.eq3.ble.key.android.a.a.o" in original app Close_Connection_Message = message_type({ id: 0x06, label: 'CLOSE_CONNECTION' }); // This class represents "COMMAND" messages; messages sent to the Smart Lock requesting to perform one of the three commands/actions (0=Lock, 1=Unlock, 2=Open) // Java class "de.eq3.ble.key.android.a.a.p" in original app Command_Message = message_type({ id: 0x87, label: 'COMMAND', encode: function(data) { return [data.command_id]; }, properties: { command_id: function() { return this.data_bytes[0]; } } }); // Returns a new array obtained by concatenating all arrays passed as arguments concatenated_array = function() { return Array.prototype.concat.apply([], arguments); }; // Convert a Buffer instance <buffer> to an array of byte integers buffer_to_byte_array = function(buffer) { return [...buffer]; }; // AES-128-encrypt <data> (a byte array) with <key> (a byte array), in ECB mode, and return the encrypted data as a byte array encrypt_aes_ecb = function(data, key) { var aesjs, cipher; aesjs = require('aes-js'); cipher = new aesjs.ModeOfOperation.ecb(key); return buffer_to_byte_array(cipher.encrypt(data)); }; // Returns the smallest value equal or larger than <value> that equals (<minimum> + (x * <step>)) for a natural number x generic_ceil = function(value, step, minimum) { step = first_valid_value(step, 1); minimum = first_valid_value(minimum, 0); return (Math.ceil((value - minimum) / step) * step) + minimum; }; // Extract the byte with index <byte_index> from the multi-byte integer value <integer>. 0 is the lowest/least significant byte index extract_byte = function(integer, byte_index) { return (integer >> (byte_index * 8)) & 0xFF; }; // Convert the integer value <integer> to a low-endian byte array of length <number_of_bytes> integer_to_byte_array = function(integer, number_of_bytes) { var byte_index, i, ref, results; results = []; for (byte_index = i = ref = number_of_bytes - 1; i >= 0; byte_index = i += -1) { results.push(extract_byte(integer, byte_index)); } return results; }; // Returns a function with argument <value> that returns true if <value> is of type <type_string>, false otherwise is_of_type = function(type_string) { return function(value) { return typeof value === type_string; }; }; // Returns true if the passed argument <value> is of type "function", false otherwise is_function = is_of_type('function'); // Create a new array of length <length>, filled with <element>. If <element> is a function, it will be called with the index of the element to be created as argument, and must return the element at this index create_array_of_length = function(length, element) { var create_element, i, index, ref, results; create_element = (is_function(element) ? element : (function() { return element; })); results = []; for (index = i = 0, ref = length; i < ref; index = i += 1) { results.push(create_element(index)); } return results; }; // Returns a new array by padding array <array> with as many <pad_element> elements until it has length <length> padded_array = function(array, length, pad_element) { return concatenated_array(array, create_array_of_length(Math.max(length - array.length, 0), pad_element)); }; // XOR the byte array <byte_array> with <xor_byte_array>. Returns a new byte array with the same length as <byte_array>. The first byte in <byte_array> will be XORed with the byte at index <xor_byte_array_offset> in <xor_byte_array> (default: 0), if the end of <xor_byte_array> is reached, it will begin at the start of <byte_array> again xor_array = function(byte_array, xor_byte_array, xor_byte_array_offset) { var byte, i, index, len, results; xor_byte_array_offset = first_valid_value(xor_byte_array_offset, 0); results = []; for (index = i = 0, len = byte_array.length; i < len; index = ++i) { byte = byte_array[index]; results.push(byte ^ xor_byte_array[(xor_byte_array_offset + index) % xor_byte_array.length]); } return results; }; // Compute the authentication value for <data> (a byte array) with message type ID <message_type_id> (an integer), session-open-nonce <session_open_nonce> (a byte array), security_counter <security_counter> (an integer) and AES-128-key <key> (a byte array) compute_authentication_value = function(data, message_type_id, session_open_nonce, security_counter, key) { var data_length, encrypted_xor_data, i, nonce, padded_data, padded_data_length, padded_data_offset, ref; nonce = compute_nonce(message_type_id, session_open_nonce, security_counter); data_length = data.length; padded_data_length = generic_ceil(data_length, 16, 0); padded_data = padded_array(data, padded_data_length, 0); encrypted_xor_data = encrypt_aes_ecb(concatenated_array([9], nonce, integer_to_byte_array(data_length, 2)), key); for (padded_data_offset = i = 0, ref = padded_data_length; i < ref; padded_data_offset = i += 0x10) { encrypted_xor_data = encrypt_aes_ecb(xor_array(encrypted_xor_data, padded_data, padded_data_offset), key); } return xor_array(encrypted_xor_data.slice(0, 4), encrypt_aes_ecb(concatenated_array([1], nonce, [0, 0]), key)); }; // This class represents "CONNECTION_INFO" messages; messages with informations like the remote session nonce etc.. Sent by the Smart Lock in response to CONNECTION_REQUEST messages // Java class "de.eq3.ble.key.android.a.a.q" in original app Connection_Info_Message = message_type({ id: 0x03, label: 'CONNECTION_INFO', properties: { user_id: function() { return this.data_bytes[0]; }, remote_session_nonce: function() { return this.data_bytes.slice(1, 9); }, bootloader_version: function() { return this.data_bytes[10]; }, application_version: function() { return this.data_bytes[11]; } } }); // This class represents "CONNECTION_REQUEST" messages; messages sent to the Smart Lock in order to set up a secure connection // Java class "de.eq3.ble.key.android.a.a.r" in original app Connection_Request_Message = message_type({ id: 0x02, label: 'CONNECTION_REQUEST', encode: function(data) { return concatenated_array([data.user_id], data.local_session_nonce); }, properties: { user_id: function() { return this.data_bytes[0]; }, local_session_nonce: function() { return this.data_bytes.slice(1, 9); } } }); // This class represents "STATUS_CHANGED_NOTIFICATION" messages // Java class "de.eq3.ble.key.android.a.a.ae" in original app Status_Changed_Notification_Message = message_type({ id: 0x05, label: 'STATUS_CHANGED_NOTIFICATION' }); // This class represents "STATUS_INFO" messages // Java class "de.eq3.ble.key.android.a.a.af" in original app Status_Info_Message = message_type({ id: 0x83, label: 'STATUS_INFO', properties: { a: function() { return bit_is_set(this.data_bytes[0], 6); }, user_right_type: function() { return (this.data_bytes[0] & 0x30) >> 4; }, e: function() { return bit_is_set(this.data_bytes[1], 7); }, f: function() { return bit_is_set(this.data_bytes[1], 4); }, g: function() { return bit_is_set(this.data_bytes[1], 0); }, h: function() { return bit_is_set(this.data_bytes[2], 5); }, i: function() { return bit_is_set(this.data_bytes[2], 4); }, j: function() { return bit_is_set(this.data_bytes[2], 3); }, lock_status: function() { return this.data_bytes[2] & 0x07; }, l: function() { return this.data_bytes[4]; }, m: function() { return this.data_bytes[5]; } } }); // Canonicalize hexadecimal string <hex_string> by removing all non-hexadecimal characters, and converting all hex digits to lower case canonicalize_hex_string = function(hex_string) { return hex_string.replace(/[^0-9A-Fa-f]/g, '').toLowerCase(); }; // Returns an array with chunks/slices of <slicable>. Each chunk/slice has the same length <chunk_length> (except for the last chunk/slice, which may have a smaller length) split_into_chunks = function(slicable, chunk_length) { var i, index, ref, ref1, results; results = []; for (index = i = 0, ref = slicable.length, ref1 = chunk_length; ref1 !== 0 && (ref1 > 0 ? i < ref : i > ref); index = i += ref1) { results.push(slicable.slice(index, index + chunk_length)); } return results; }; // Convert the hexadecimal string <hex_string> to a byte array hex_string_to_byte_array = function(hex_string) { var byte_hex_string, i, len, ref, results; ref = split_into_chunks(canonicalize_hex_string(hex_string), 2); results = []; for (i = 0, len = ref.length; i < len; i++) { byte_hex_string = ref[i]; results.push(parseInt(byte_hex_string, 0x10)); } return results; }; // Returns true if the passed argument <value> is a Buffer instance, false otherwise is_buffer = function(value) { return Buffer.isBuffer(value); }; // Returns true if the passed argument <value> is a string, false otherwise is_string = is_of_type('string'); // Convert <value>, which may either be a byte array, a hexadecimal string or a Buffer instance, to a byte array. If <value> is neither of those, null is returned convert_to_byte_array = function(value) { if (is_array(value)) { return value; } if (is_string(value)) { return hex_string_to_byte_array(value); } if (is_buffer(value)) { return buffer_to_byte_array(value); } return null; }; // Returns a random integer value, in the range from <minimum_value> (inclusive, default:0) to <maximum_value_exclusive> (exclusive) create_random_integer = function(maximum_value, minimum_value) { minimum_value = first_valid_value(minimum_value, 0); return Math.floor(Math.random() * (maximum_value - minimum_value)) + minimum_value; }; // Returns a single random integer in the byte range create_random_byte = function() { return create_random_integer(0x100); }; // Create a new array of length <length>, filled with random byte values create_random_byte_array = function(length) { return create_array_of_length(length, create_random_byte); }; // Compute the "nonce" for a message with type ID <message_type_id>, session-open-nonce <session_open_nonce>, and security counter <security_counter> compute_nonce = function(message_type_id, session_open_nonce, security_counter) { return concatenated_array([message_type_id], session_open_nonce, [0, 0], integer_to_byte_array(security_counter, 2)); }; // Encrypt/Decrypt <data> (a byte array) with message type ID <message_type_id> (an integer), session-open-nonce <session_open_nonce> (a byte array), security_counter <security_counter> (an integer) and AES-128-key <key> (a byte array). If <data> is decrypted, it will be encrypted; if it is encrypted, it will be decrypted crypt_data = function(data, message_type_id, session_open_nonce, security_counter, key) { var i, index, nonce, ref, xor_data; nonce = compute_nonce(message_type_id, session_open_nonce, security_counter); xor_data = []; for (index = i = 0, ref = Math.floor(generic_ceil(data.length, 16, 0) / 0x10); i < ref; index = i += 1) { xor_data = concatenated_array(xor_data, encrypt_aes_ecb(concatenated_array([1], nonce, integer_to_byte_array(index + 1, 2)), key)); } return xor_array(data, xor_data); }; // Debug output function for keyble Bluetooth communication debug_communication = require('debug')('keyble:communication'); // Debug output function for keyble events debug_events = require('debug')('keyble:events'); // Import/Require the "events" module = the EventEmitter class Event_Emitter = require('events'); // This class represents "FRAGMENT_ACK" messages; messages that acknowledge the receival of a fragment of a message (except for the last one) // Java class "de.eq3.ble.key.android.a.a.t" in original app Fragment_Ack_Message = message_type({ id: 0x00, label: 'FRAGMENT_ACK', encode: function(data) { return [data.fragment_id]; }, properties: { fragment_id: function() { return this.data_bytes[0]; } } }); // This class represents a message fragment. Bluetooth characteristics can only transfer a very limited number of bytes at once, so larger messages need to be split into several fragments/parts Message_Fragment = Extendable.extend({ initialize: function(byte_array1) { this.byte_array = byte_array1; }, get_status_byte: function() { return this.byte_array[0]; }, get_number_of_remaining_fragments: function() { return this.get_status_byte() & 0x7F; }, get_message_type_id: function() { if (!this.is_first()) { throw new Error('Is not first fragment'); } return this.byte_array[1]; }, is_first: function() { return bit_is_set(this.get_status_byte(), 7); }, is_last: function() { return this.get_number_of_remaining_fragments() === 0; }, is_complete_message: function() { return this.is_first() && this.is_last(); }, get_data_byte_array: function() { return this.byte_array.slice(this.is_first() ? 2 : 1); } }); // Convert an array of objects <objects_array> to an object of objects, where each property key/name is the value of property <property_name> of the object, and the property value is the object itself dictify_array = function(objects_array, property_name) { var i, len, object, temp; temp = {}; for (i = 0, len = objects_array.length; i < len; i++) { object = objects_array[i]; temp[object[property_name]] = object; } return temp; }; // This class represents "ANSWER_WITH_SECURITY" messages // Java class "de.eq3.ble.key.android.a.a.e" in original app Answer_With_Security_Message = message_type({ id: 0x81, label: 'ANSWER_WITH_SECURITY', properties: { a: function() { return (this.data_bytes[0] & 0x80) === 0; }, b: function() { return (this.data_bytes[0] & 0x81) === 1; } } }); // This class represents "ANSWER_WITHOUT_SECURITY" messages // Java class "de.eq3.ble.key.android.a.a.f" in original app Answer_Without_Security_Message = message_type({ id: 0x01, label: 'ANSWER_WITHOUT_SECURITY', properties: { a: function() { return (this.data_bytes[0] & 0x80) === 0; }, b: function() { return (this.data_bytes[0] & 0x81) === 1; } } }); // This class represents "PAIRING_REQUEST" messages // Java class "de.eq3.ble.key.android.a.a.ac" in original app Pairing_Request_Message = message_type({ id: 0x04, label: 'PAIRING_REQUEST', encode: function(data) { return concatenated_array([data.user_id], padded_array(data.encrypted_pair_key, 22, 0), integer_to_byte_array(data.security_counter, 2), data.authentication_value); }, properties: { user_id: function() { return this.data_bytes[0]; }, encrypted_pair_key: function() { return this.data_bytes.slice(1, 23); }, security_counter: function() { return byte_array_to_integer(this.data_bytes, 23, 2); }, authentication_value: function() { return this.data_bytes.slice(25, 29); } } }); // This class represents "STATUS_REQUEST" messages; messages sent to the Smart Lock, informing the current date/time, and requesting status information // Java class "de.eq3.ble.key.android.a.a.ag" in original app Status_Request_Message = message_type({ id: 0x82, label: 'STATUS_REQUEST', encode: function(data) { var date; date = data.date; return [date.getFullYear() - 2000, date.getMonth() + 1, date.getDate(), date.getHours(), date.getMinutes(), date.getSeconds()]; }, properties: { date: function() { return new Date(this.data_bytes[0] + 2000, this.data_bytes[1] - 1, this.data_bytes[2], this.data_bytes[3], this.data_bytes[4], this.data_bytes[5]); } } }); // This class represents "USER_INFO" messages // Java class "de.eq3.ble.key.android.a.a.ah" in original app User_Info_Message = message_type({ id: 0x8f, label: 'USER_INFO' }); // Convert string <string> to a UTF-8 byte array string_to_utf8_byte_array = function(string) { return buffer_to_byte_array(Buffer.from(string, 'utf8')); }; // Convert UTF-8 encoded byte array <byte_array> to a String utf8_byte_array_to_string = function(byte_array) { return Buffer.from(byte_array).toString('utf8'); }; // This class represents "USER_NAME_SET" messages; messages sent to the Smart Lock requesting to change a user name // Java class "de.eq3.ble.key.android.a.a.al" in original app User_Name_Set_Message = message_type({ id: 0x90, label: 'USER_NAME_SET', encode: function(data) { return concatenated_array([data.user_id], padded_array(string_to_utf8_byte_array(data.user_name), 20, 0)); }, properties: { user_id: function() { return this.data_bytes[0]; }, user_name: function() { return utf8_byte_array_to_string(this.data_bytes.slice(1, this.data_bytes.indexOf(0, 1))); } } }); // An array of all (currently implemented) message types message_types = [Fragment_Ack_Message, Answer_Without_Security_Message, Connection_Request_Message, Connection_Info_Message, Pairing_Request_Message, Status_Changed_Notification_Message, Close_Connection_Message, Answer_With_Security_Message, Status_Request_Message, Status_Info_Message, Command_Message, User_Info_Message, User_Name_Set_Message]; // An object that has the various message types as properties, and the labels of these message types as property names/keys message_types_by_id = dictify_array(message_types, 'id'); // Import/Require the "simble" module for communicating with Bluetooth Low Energy peripherals simble = require('simble'); // An object with the possible Key_Ble states state = { disconnected: 0, connected: 1, nonces_exchanged: 2, secured: 3 }; // A class that represents the eQ-3 eqiva Bluetooth smart lock Key_Ble = class extends Event_Emitter { constructor(options) { super(); this.address = simble.canonicalize.address(options.address); this.user_id = first_valid_value(options.user_id, 0xFF); this.user_key = convert_to_byte_array(options.user_key); this.auto_disconnect_time = first_valid_value(options.auto_disconnect_time, 15.0); this.set_status_update_time(options.status_update_time); this.received_message_fragments = []; this.local_security_counter = 1; this.remote_security_counter = 0; this.state = state.disconnected; this.lock_status_id = null; return; } set_status_update_time(status_update_time) { this.status_update_time = first_valid_value(status_update_time, 600.0); this.set_status_update_timer(); } // Await up to <timeout> (default: 1000) milliseconds for the event with ID <event_id> (a string). If <timeout> is 0, wait forever. Returns a Promise that resolves when the event occurs, and rejects if a timeout situation occurs await_event(event_id) { return new Promise((resolve, reject) => { this.once(event_id, function(...args) { resolve(args); }); }); } await_message(message_type) { return this.await_event(`received:message:${message_type}`); } set_user_name(user_name, user_id) { user_id = first_valid_value(user_id, this.user_id); return this.send_message(User_Name_Set_Message.create({ user_id: user_id, user_name: user_name })).then(() => { return this.await_message('USER_INFO'); }); } pairing_request(card_key) { card_key = convert_to_byte_array(card_key); this.user_key = create_random_byte_array(16); return this.ensure_nonces_exchanged().then(() => { return this.send_message(Pairing_Request_Message.create({ user_id: this.user_id, encrypted_pair_key: crypt_data(this.user_key, Pairing_Request_Message.id, this.remote_session_nonce, this.local_security_counter, card_key), security_counter: this.local_security_counter, authentication_value: compute_authentication_value(padded_array(concatenated_array([this.user_id], this.user_key), 23, 0), Pairing_Request_Message.id, this.remote_session_nonce, this.local_security_counter, card_key) })); }).then(() => { return this.await_message('ANSWER_WITH_SECURITY'); }).then(() => { return { user_id: this.user_id, user_key: byte_array_to_hex_string(this.user_key, '') }; }); } emit(event_id) { debug_events(`Event: ${event_id}`); return super.emit(...arguments); } // Lock the smart lock lock() { if (this.lock_status_id === 3) { return Promise.resolve(); } return this.send_command(0).then(() => { return this.await_event('status:LOCKED'); }); } // Unlock the smart lock unlock() { if (this.lock_status_id === 2) { return Promise.resolve(); } return this.send_command(1).then(() => { return this.await_event('status:UNLOCKED'); }); } // Open the smart lock open() { if (this.lock_status_id === 4) { return Promise.resolve(); } return this.send_command(2).then(() => { return this.await_event('status:OPENED'); }); } // Send a COMMAND message with command/action ID <command_id> (0 = lock, 1 = unlock, 2 = open) send_command(command_id) { return this.send_message(Command_Message.create({ command_id: command_id })); } on_message_fragment_received(message_fragment) { var Message_Type, computed_authentication_value, message_authentication_value, message_data_bytes, message_security_counter; this.received_message_fragments.push(message_fragment); this.emit('received:fragment', message_fragment); if (message_fragment.is_last()) { message_data_bytes = this.received_message_fragments.reduce(function(byte_array, message_fragment) { return concatenated_array(byte_array, message_fragment.get_data_byte_array()); }, []); Message_Type = message_types_by_id[this.received_message_fragments[0].get_message_type_id()]; if (Message_Type.is_secure()) { message_security_counter = byte_array_to_integer(message_data_bytes, -6, -4); if (message_security_counter <= this.remote_security_counter) { throw new Error('Received message contains invalid security counter'); } message_authentication_value = message_data_bytes.slice(-4); this.remote_security_counter = message_security_counter; message_data_bytes = crypt_data(message_data_bytes.slice(0, -6), Message_Type.id, this.local_session_nonce, this.remote_security_counter, this.user_key); computed_authentication_value = compute_authentication_value(message_data_bytes, Message_Type.id, this.local_session_nonce, this.remote_security_counter, this.user_key); if (!arrays_are_equal(message_authentication_value, computed_authentication_value)) { throw new Error('Received message contains invalid authentication value'); } } this.received_message_fragments = []; this.on_message_received(Message_Type.create(message_data_bytes)); } else { this.send_message(Fragment_Ack_Message.create({ fragment_id: message_fragment.get_status_byte() })); } } set_status_update_timer() { clearTimeout(this.status_update_timer); if (this.status_update_time > 0) { return this.status_update_timer = setTimeout(() => { this.request_status(); }, this.status_update_time * 1000); } } on_message_received(message) { var lock_status_id, lock_status_string; debug_communication(`Received message of type ${message.label}, data bytes <${byte_array_to_hex_string(message.data_bytes, ' ')}>, data ${JSON.stringify(message.data)}`); this.emit('received:message', message); this.emit(`received:message:${message.label}`, message); switch (message.__type__) { case Connection_Info_Message: this.user_id = message.data.user_id; this.remote_session_nonce = message.data.remote_session_nonce; this.local_security_counter = 1; this.remote_security_counter = 0; break; case Status_Info_Message: lock_status_id = message.data.lock_status; lock_status_string = { 0: 'UNKNOWN', 1: 'MOVING', 2: 'UNLOCKED', 3: 'LOCKED', 4: 'OPENED' }[lock_status_id]; this.emit('status_update', lock_status_id, lock_status_string); if (this.lock_status_id !== lock_status_id) { this.lock_status_id = lock_status_id; this.emit(`status:${lock_status_string}`, lock_status_id, lock_status_string); this.emit('status_change', lock_status_id, lock_status_string); } this.set_status_update_timer(); break; case Status_Changed_Notification_Message: this.request_status(); } } send_message_fragment(message_fragment) { return this.ensure_connected().then(() => { var ack_promise, send_promise; send_promise = this.send_characteristic.write(message_fragment.byte_array); ack_promise = ((!message_fragment.is_last()) ? this.await_message('FRAGMENT_ACK') : Promise.resolve()); return Promise.all([send_promise, ack_promise]); }); } send_message_fragments(message_fragments) { var send_message_fragment_with_index; send_message_fragment_with_index = (message_fragment_index) => { var message_fragment; if (message_fragment_index < message_fragments.length) { message_fragment = message_fragments[message_fragment_index]; return this.send_message_fragment(message_fragment).then(function() { return send_message_fragment_with_index(message_fragment_index + 1); }); } else { return Promise.resolve(); } }; return send_message_fragment_with_index(0); } send_message(message) { return (message.is_secure() ? this.ensure_nonces_exchanged() : this.ensure_connected()).then(() => { var message_data_bytes, message_fragments, padded_data; debug_communication(`Sending message of type ${message.label}, data bytes <${byte_array_to_hex_string(message.data_bytes, ' ')}>, data ${JSON.stringify(message.data)}`); if (message.is_secure()) { padded_data = padded_array(message.data_bytes, generic_ceil(message.data_bytes.length, 15, 8), 0); crypt_data(padded_data, message.id, this.remote_session_nonce, this.local_security_counter, this.user_key); message_data_bytes = concatenated_array(crypt_data(padded_data, message.id, this.remote_session_nonce, this.local_security_counter, this.user_key), integer_to_byte_array(this.local_security_counter, 2), compute_authentication_value(padded_data, message.id, this.remote_session_nonce, this.local_security_counter, this.user_key)); this.local_security_counter++; } else { message_data_bytes = message.data_bytes; } message_fragments = split_into_chunks(concatenated_array([message.id], message_data_bytes), 15).map(function(fragment_bytes, index, chunks) { return Message_Fragment.create(concatenated_array([(chunks.length - 1 - index) + (index === 0 ? 0x80 : 0x00)], padded_array(fragment_bytes, 15, 0))); }); return this.send_message_fragments(message_fragments); }); } ensure_peripheral() { if (this.peripheral) { return Promise.resolve(this.peripheral); } return simble.scan_for_peripheral(simble.filter.address(this.address)).then((peripheral) => { return peripheral.ensure_discovered(); }).then((peripheral1) => { var communication_service; this.peripheral = peripheral1; this.peripheral.set_auto_disconnect_time(this.auto_disconnect_time * 1000); this.peripheral.on('connected', () => { this.state = state.connected; this.emit('connected'); }); this.peripheral.on('disconnected', () => { this.state = state.disconnected; this.emit('disconnected'); }); communication_service = this.peripheral.get_discovered_service('58e06900-15d8-11e6-b737-0002a5d5c51b'); this.send_characteristic = communication_service.get_discovered_characteristic('3141dd40-15db-11e6-a24b-0002a5d5c51b'); this.receive_characteristic = communication_service.get_discovered_characteristic('359d4820-15db-11e6-82bd-0002a5d5c51b'); return this.receive_characteristic.subscribe((message_fragment_bytes) => { return this.on_message_fragment_received(Message_Fragment.create(message_fragment_bytes)); }); }); } ensure_connected() { return this.ensure_peripheral().then(() => { return ((this.state >= state.connected) ? Promise.resolve() : this.peripheral.ensure_discovered()); }); } ensure_nonces_exchanged() { if (this.state >= state.nonces_exchanged) { return Promise.resolve(); } this.local_session_nonce = create_random_byte_array(8); return this.send_message(Connection_Request_Message.create({ user_id: this.user_id, local_session_nonce: this.local_session_nonce })).then(() => { return this.await_message('CONNECTION_INFO'); }).then(() => { this.state = state.nonces_exchanged; this.emit('nonces_exchanged'); }); } request_status() { return this.send_message(Status_Request_Message.create({ date: new Date() })).then(() => { return this.await_event('status_update'); }); } ensure_disconnected() { if (this.state < state.connected) { return Promise.resolve(); } return this.send_message(Close_Connection_Message.create()).then(() => { return this.peripheral.disconnect(); }); } disconnect() { return this.ensure_disconnected(); } }; // The pattern of the data encoded in the QR-Code on the "Key Card"s of the eQ-3 eqiva Bluetooth smart locks, as a string key_card_data_pattern = '^M([0-9A-F]{12})K([0-9A-F]{32})([0-9A-Z]{10})$'; // The pattern of the data encoded in the QR-Code on the "Key Card"s of the eQ-3 eqiva Bluetooth smart locks, as a regular expression/RegExp key_card_data_regexp = new RegExp(key_card_data_pattern); // Convert byte array <byte_array> into several formats/represenations. Returns an array with "buffer" (the byte array as a Buffer instance), "array" (the original byte array), "short" (the byte array as a short hexadecimal string without any non-hexadecimal characters) and "long" (the byte array as a long hexadecimal string, where the bytes are separated by string <long_format_separator> (default: ' ')) properties byte_array_formats = function(byte_array, long_format_separator) { byte_array = convert_to_byte_array(byte_array); long_format_separator = first_valid_value(long_format_separator, ' '); return { array: byte_array, buffer: Buffer.from(byte_array), long: byte_array_to_hex_string(byte_array, long_format_separator), short: byte_array_to_hex_string(byte_array, '') }; }; // Parse the data string encoded in the QR-Code of the "Key Card"s of the eQ-3 eqiva Bluetooth smart locks. Returns an object with "address", "register_key" and "serial" properties parse_key_card_data = function(key_card_data_string) { var match; match = key_card_data_string.trim().match(key_card_data_regexp); if (!match) { throw new Error('Not a valid Key Card data string'); } return { address: byte_array_formats(hex_string_to_byte_array(match[1]), ':').long, register_key: byte_array_formats(hex_string_to_byte_array(match[2]), ' ').short, serial: match[3] }; }; // Returns a promise that is a time-limited wrapper for promise <promise>. If the promise <promise> does not resolve within <time_limit> milliseconds, the promise is rejected time_limit_promise = function(promise, time_limit, timeout_error_message) { if (time_limit === 0) { return promise; } timeout_error_message = first_valid_value(timeout_error_message, `Promise did not resolve within ${time_limit} milliseconds`); return new Promise(function(resolve, reject) { var timeout; timeout = setTimeout(function() { reject(timeout_error_message); }, time_limit); Promise.resolve(promise).then(function(promise_result) { clearTimeout(timeout); resolve(promise_result); }).catch(function(promise_error) { clearTimeout(timeout); reject(promise_error); }); }); }; // What this module exports module.exports = { Key_Ble: Key_Ble, key_card: { parse: parse_key_card_data, pattern: key_card_data_pattern, regexp: key_card_data_regexp }, utils: { time_limit: time_limit_promise } }; //# sourceMappingURL=keyble.js.map