UNPKG

advlib-ble-manufacturers

Version:

Wireless advertising packet decoding library for Bluetooth Low Energy manufacturer-specific data. We believe in an open Internet of Things.

655 lines (563 loc) 22 kB
/** * Copyright reelyActive 2021-2025 * We believe in an open Internet of Things */ const utils = require('./utils'); const MIN_DATA_LENGTH_BYTES = 1; const MSE_MIN_FRAME_LENGTH = 7; const S3_STATIC_FRAME_LENGTH = 20; const S3_TEMPERATURE_HUMIDITY_FRAME_LENGTH = 16; const S4_STATIC_FRAME_LENGTH = 18; const S4_DOOR_ALARM_FRAME_LENGTH = 15; const FRAME_VERSION_OFFSET = 1; const FIRMWARE_VERSION_OFFSET = 2; const HARDWARE_VERSION_OFFSET = 5; const BATTERY_PERCENTAGE_OFFSET = 2; const DOOR_MAGNET_ALARM_OFFSET = 3; const ANTI_DISASSEMBLY_ALARM_OFFSET = 4; const ALARM_TRIGGER_OFFSET = 5; const S3_TEMPERATURE_OFFSET = 3; const S3_HUMIDITY_OFFSET = 5; const S3_STATIC_MAC_OFFSET = 12; const S3_TEMPERATURE_HUMIDITY_MAC_OFFSET = 7; const S4_STATIC_MAC_OFFSET = 10; const S4_DOOR_ALARM_MAC_OFFSET = 7; const V3_00_FRAME_LENGTH = 24; const V3_05_FRAME_LENGTH = 24; const V3_18_FRAME_LENGTH = 24; const V3_1B_FRAME_LENGTH = 24; const V3_1E_FRAME_LENGTH = 24; const V3_SUBFRAME_VERSION_OFFSET = 2; const V3_00_MAC_OFFSET = 2; const V3_00_BATTERY_PERCENTAGE_OFFSET = 8; const V3_00_FIRMWARE_VERSION_OFFSET = 9; const V3_00_SALT_OFFSET = 20; const V3_00_DIGITAL_SIGNATURE_OFFSET = 22; const V3_03_MIN_FRAME_LENGTH = 6; const V3_03_POST_BLOCK_LENGTH = 4; const V3_05_DEVICE_INFO_OFFSET = 3; const V3_05_TEMPERATURE_OFFSET = 5; const V3_05_HUMIDITY_OFFSET = 7; const V3_05_NAME_OFFSET = 9; const V3_05_MARK_OFFSET = 17; const V3_05_SALT_OFFSET = 20; const V3_05_DIGITAL_SIGNATURE_OFFSET = 22; const V3_05_NAME_LENGTH = 8; const V3_05_DEVICE_INFO_TEMPERATURE_ONLY_MASK = 0x02; const V3_18_SERIAL_NUMBER_OFFSET = 3; const V3_18_NUMBER_OF_ENTRIES_OFFSET = 4; const V3_18_NUMBER_OF_EXITS_OFFSET = 6; const V3_18_NUMBER_OF_PEOPLE_OFFSET = 4; const V3_18_SALT_OFFSET = 20; const V3_18_DIGITAL_SIGNATURE_OFFSET = 22; const V3_1B_SUBVERSION_OFFSET = 2; const V3_1B_PRODUCT_ID_OFFSET = 3; const V3_1B_BLOCKS_OFFSET = 3; const V3_1B_STATIC_MAC_OFFSET = 6; const V3_1B_BATTERY_VOLTAGE_OFFSET = 12; const V3_1B_TEMPERATURE_OFFSET = 14; const V3_1B_TX_POWER_OFFSET = 17; const V3_1B_ENCRYPTION_MASK = 0x80; const V3_1B_SUBVERSION_MASK = 0x7f; const V3_1E_DISTANCE_CYCLE_OFFSET = 3; const V3_1E_DISTANCE_OFFSET = 4; const V3_1E_FLAGS_OFFSET = 6; const V3_1E_OCCUPANCY_DETECTION_CYCLE_OFFSET = 7; const V3_1E_TAMPER_CYCLE_OFFSET = 8; const V3_1E_SALT_OFFSET = 20; const V3_1E_DIGITAL_SIGNATURE_OFFSET = 22; const V3_1E_LOW_BATTERY_MASK = 0x08; const V3_1E_OCCUPANCY_DETECTION_MASK = 0x04; const V3_1E_MOTION_DETECTION_MASK = 0x02; const V3_1E_CONTACT_MASK = 0x01; const IDENTIFIER_TYPE_EUI_48 = 2; const MINEW_URI = "https://sniffypedia.org/Organization/Shenzhen_Minew_Technologies_Co_Ltd/"; const MSE_PRODUCT_URIS = new Map([ [ 0x01, "https://sniffypedia.org/Product/Minew_MSE01/" ], [ 0x02, "https://sniffypedia.org/Product/Minew_MSE02/" ] ]); const V3_PRODUCT_URIS = new Map([ [ 0x007, "https://sniffypedia.org/Product/Minew_MTB02/" ] ]); const V3_COMBINATION_BLOCKS = new Map([ [ 0x03, { length: 7, process: processV3CombinationBlock03 } ], [ 0x1f, { length: 5, process: processV3CombinationBlock1F } ], [ 0x21, { length: 2, process: processV3CombinationBlock21 } ], [ 0x22, { length: 2, process: processV3CombinationBlock22 } ] ]); const V3_SENSOR_BLOCKS = new Map([ [ 0x01, { length: 6, process: processV3SensorBlock01 } ], [ 0x02, { length: 7, process: processV3SensorBlock02 } ], [ 0x03, { length: 3, process: processV3SensorBlock03 } ] ]); /** * Process Minew manufacturer-specific data. * @param {Object} data The manufacturer data as a hexadecimal-string or Buffer. * @return {Object} The processed Minew data as JSON. */ function process(data) { let buf = utils.convertToBuffer(data); if((buf === null) || (buf.length < MIN_DATA_LENGTH_BYTES)) { return null; } let frameType = buf.readUInt8(); switch(frameType) { case 0x51: return processMSE(buf); case 0xa3: return processS3(buf); case 0xa4: return processS4(buf); case 0xca: return processConnectV3(buf); } return null; } /** * Process Minew MSE sensor data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processMSE(data) { if(data.length < MSE_MIN_FRAME_LENGTH) { return null; } let model = data.readUInt8(1); let txCycle = data.readUInt8(2); let batteryPercentage = data.readUInt8(3); let sensorStatus = data.readUInt8(4); let isButtonPressed = [ (sensorStatus & 0x20) === 0x20, (sensorStatus & 0x40) === 0x40 ]; let isTamperDetected = [ (sensorStatus & 0x01) === 0x01 ]; let isMotionDetected = [ (sensorStatus & 0x02) === 0x02 ]; let energyPercentage = 100 * data.readUInt16LE(5) / 4095; let uri = MSE_PRODUCT_URIS.has(model) ? MSE_PRODUCT_URIS.get(model) : MINEW_URI; // TODO: process optional relay data return { batteryPercentage: batteryPercentage, isButtonPressed: isButtonPressed, isTamperDetected: isTamperDetected, isMotionDetected: isMotionDetected, levelPercentage: energyPercentage, txCycle: txCycle, uri: uri }; } /** * Process Minew S3 data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processS3(data) { let frameVersion = data.readUInt8(FRAME_VERSION_OFFSET); if((frameVersion === 1) && // Static information frame (data.length === S3_STATIC_FRAME_LENGTH)) { let firmwareVersion = data.readUInt8(FIRMWARE_VERSION_OFFSET) + '.' + data.readUInt8(FIRMWARE_VERSION_OFFSET + 1) + '.' + data.readUInt8(FIRMWARE_VERSION_OFFSET + 2); let hardwareVersion = data.readUInt16BE(HARDWARE_VERSION_OFFSET).toString(); let macSignature = toMacSignature(data, S3_STATIC_MAC_OFFSET); return { deviceIds: [ macSignature ], firmwareVersion: firmwareVersion, hardwareVersion: hardwareVersion, uri: MINEW_URI }; } else if((frameVersion === 3) && // Temperature humidity frame (data.length === S3_TEMPERATURE_HUMIDITY_FRAME_LENGTH)) { let batteryPercentage = data.readUInt8(BATTERY_PERCENTAGE_OFFSET); let temperature = utils.parseSigned88(data.subarray(S3_TEMPERATURE_OFFSET)); let relativeHumidity = utils.parseSigned88(data.subarray(S3_HUMIDITY_OFFSET)); let macSignature = toMacSignature(data, S3_TEMPERATURE_HUMIDITY_MAC_OFFSET); return { batteryPercentage: batteryPercentage, deviceIds: [ macSignature ], relativeHumidity: relativeHumidity, temperature: temperature, uri: MINEW_URI }; } return null; } /** * Process Minew S4 data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processS4(data) { let frameVersion = data.readUInt8(FRAME_VERSION_OFFSET); if((frameVersion === 0) && // Static information frame (data.length === S4_STATIC_FRAME_LENGTH)) { let firmwareVersion = data.readUInt8(FIRMWARE_VERSION_OFFSET) + '.' + data.readUInt8(FIRMWARE_VERSION_OFFSET + 1) + '.' + data.readUInt8(FIRMWARE_VERSION_OFFSET + 2); let hardwareVersion = data.readUInt16BE(HARDWARE_VERSION_OFFSET).toString(); let macSignature = toMacSignature(data, S4_STATIC_MAC_OFFSET); return { deviceIds: [ macSignature ], firmwareVersion: firmwareVersion, hardwareVersion: hardwareVersion, uri: MINEW_URI }; } else if((frameVersion === 1) && // Door alarm frame (data.length === S4_DOOR_ALARM_FRAME_LENGTH)) { let batteryPercentage = data.readUInt8(BATTERY_PERCENTAGE_OFFSET); let isDoorMagnetAlarm = (data.readUInt8(DOOR_MAGNET_ALARM_OFFSET) > 0); let isAntiDisassemblyAlarm = (data.readUInt8( ANTI_DISASSEMBLY_ALARM_OFFSET) > 0); let isCurrentAlarmTrigger = (data.readUInt8(ALARM_TRIGGER_OFFSET) > 0); let macSignature = toMacSignature(data, S4_DOOR_ALARM_MAC_OFFSET); return { batteryPercentage: batteryPercentage, deviceIds: [ macSignature ], isContactDetected: [ !isDoorMagnetAlarm ], isTamperDetected: [ !isAntiDisassemblyAlarm ], uri: MINEW_URI }; } return null; } /** * Process Minew Connect V3 data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processConnectV3(data) { let frameVersion = data.readUInt8(FRAME_VERSION_OFFSET); switch(frameVersion) { case 0x00: return processConnectV3Type00(data); case 0x03: return processConnectV3Type03(data); case 0x05: return processConnectV3Type05(data); case 0x18: return processConnectV3Type18(data); case 0x1b: return processConnectV3Type1b(data); case 0x1e: return processConnectV3Type1e(data); } return null; } /** * Process Minew Connect V3 type 0x00 device information frame data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processConnectV3Type00(data) { if(data.length !== V3_00_FRAME_LENGTH) { return null; } let macSignature = toMacSignature(data, V3_00_MAC_OFFSET); let batteryPercentage = data.readUInt8(V3_00_BATTERY_PERCENTAGE_OFFSET); let firmwareVersionBinary = data.readUInt16BE(V3_00_FIRMWARE_VERSION_OFFSET); let firmwareVersion = ((firmwareVersionBinary >> 13) & 0x0003) + '.' + ((firmwareVersionBinary >> 7) & 0x003f) + '.' + (firmwareVersionBinary & 0x007f); let salt = data.readUInt16BE(V3_00_SALT_OFFSET); let checksum = data.readUInt16BE(V3_00_DIGITAL_SIGNATURE_OFFSET); return { batteryPercentage: batteryPercentage, deviceIds: [ macSignature ], encrypted: { salt: salt, checksum: checksum, method: "minew-connect-v3" }, firmwareVersion: firmwareVersion, uri: MINEW_URI }; } /** * Process Minew Connect V3 type 0x03 combination frame data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processConnectV3Type03(data) { if(data.length < V3_03_MIN_FRAME_LENGTH) { return null; } let sensorData = { uri: MINEW_URI }; let currentBlockOffset = V3_SUBFRAME_VERSION_OFFSET; while(currentBlockOffset < (data.length - V3_03_POST_BLOCK_LENGTH)) { let currentBlockId = data.readUInt8(currentBlockOffset); if(V3_COMBINATION_BLOCKS.has(currentBlockId)) { let currentBlockParameters = V3_COMBINATION_BLOCKS.get(currentBlockId); let currentBlockLength = currentBlockParameters.length; let currentBlock = data.subarray(currentBlockOffset + 1, currentBlockOffset + currentBlockLength); let blockData = currentBlockParameters.process(currentBlock); Object.assign(sensorData, blockData); currentBlockOffset += currentBlockLength; } else { return sensorData; } } if(currentBlockOffset === (data.length - V3_03_POST_BLOCK_LENGTH)) { sensorData.encrypted = { salt: data.readUInt16BE(currentBlockOffset), checksum: data.readUInt16BE(currentBlockOffset + 2), method: "minew-connect-v3" }; } return sensorData; } /** * Process Minew Connect V3 type 0x05 temperature/humidity frame data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processConnectV3Type05(data) { if(data.length !== V3_05_FRAME_LENGTH) { return null; } let subframeVersion = data.readUInt8(V3_SUBFRAME_VERSION_OFFSET); let deviceInfo = data.readUInt8(V3_05_DEVICE_INFO_OFFSET); let temperature = utils.parseSigned88(data.subarray(V3_05_TEMPERATURE_OFFSET)); let relativeHumidity = utils.parseSigned88(data.subarray(V3_05_HUMIDITY_OFFSET)); let name = data.toString('utf8', V3_05_NAME_OFFSET, V3_05_NAME_OFFSET + V3_05_NAME_LENGTH).replace(/\0/g, ''); let salt = data.readUInt16BE(V3_05_SALT_OFFSET); let checksum = data.readUInt16BE(V3_05_DIGITAL_SIGNATURE_OFFSET); let sensorData = { encrypted: { salt: salt, checksum: checksum, method: "minew-connect-v3" }, name: name, temperature: temperature, uri: MINEW_URI }; if(!(deviceInfo & V3_05_DEVICE_INFO_TEMPERATURE_ONLY_MASK)) { sensorData.relativeHumidity = relativeHumidity; } if(subframeVersion > 0) { let isMarked = (data.readUInt8(V3_05_MARK_OFFSET) === 0x01); sensorData.isMarked = [ isMarked ]; } return sensorData; } /** * Process Minew Connect V3 type 18 radar monitor frame data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processConnectV3Type18(data) { if(data.length !== V3_18_FRAME_LENGTH) { return null; } let subframeVersion = data.readUInt8(V3_SUBFRAME_VERSION_OFFSET); let sensorData = { uri: MINEW_URI }; if(subframeVersion === 0x00) { // Human traffic monitoring let serialNumber = data.readUInt8(V3_18_SERIAL_NUMBER_OFFSET); let numberOfEntries = data.readUInt16LE(V3_18_NUMBER_OF_ENTRIES_OFFSET); let numberOfExits = data.readUInt16LE(V3_18_NUMBER_OF_EXITS_OFFSET); sensorData.passageCounts = [ numberOfEntries, numberOfExits ]; sensorData.passageCountsCycle = serialNumber; sensorData.encrypted = { salt: data.readUInt16BE(V3_18_SALT_OFFSET), checksum: data.readUInt16BE(V3_18_DIGITAL_SIGNATURE_OFFSET), method: "minew-connect-v3" }; } else if(subframeVersion === 0x01) { // Person coordinate info let serialNumber = data.readUInt8(V3_18_SERIAL_NUMBER_OFFSET); let statusByte = data.readUInt8(V3_18_NUMBER_OF_PEOPLE_OFFSET); let packetIndex = statusByte & 0x30; sensorData.numberOfOccupants = statusByte & 0x0f; sensorData.numberOfOccupantsCycle = serialNumber; // TODO: nearest? sensorData.encrypted = { salt: data.readUInt16BE(V3_18_SALT_OFFSET), checksum: data.readUInt16BE(V3_18_DIGITAL_SIGNATURE_OFFSET), method: "minew-connect-v3" }; } else { return null; } return sensorData; } /** * Process Minew Connect V3 type 0x1b temperature frame data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processConnectV3Type1b(data) { if(data.length < V3_1B_SUBVERSION_OFFSET) { return null; } let isEncrypted = (data.readUInt8(V3_1B_SUBVERSION_OFFSET) & V3_1B_ENCRYPTION_MASK) === V3_1B_ENCRYPTION_MASK; let subVersion = data.readUInt8(V3_1B_SUBVERSION_OFFSET) & V3_1B_SUBVERSION_MASK; if(isEncrypted) { return {}; // TODO: extract data for decryption } if(subVersion === 0x01) { let productId = data.readUInt16BE(V3_1B_PRODUCT_ID_OFFSET); let macSignature = toMacSignature(data, V3_1B_STATIC_MAC_OFFSET); let batteryVoltage = data.readUInt16BE(V3_1B_BATTERY_VOLTAGE_OFFSET) / 1000; let temperature = utils.parseSigned88(data.subarray( V3_1B_TEMPERATURE_OFFSET)); let txPower = data.readInt8(V3_1B_TX_POWER_OFFSET); let uri = V3_PRODUCT_URIS.has(productId) ? V3_PRODUCT_URIS.get(productId) : MINEW_URI; return { batteryVoltage: batteryVoltage, temperature: temperature, txPower: txPower, deviceIds: [ macSignature ], uri: uri }; } else if(subVersion === 0x02) { return processV3SensorBlocks(data.subarray(V3_1B_BLOCKS_OFFSET)); } return { uri: uri }; } /** * Process Minew Connect V3 type 0x1e ToF monitor frame data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processConnectV3Type1e(data) { if(data.length !== V3_1E_FRAME_LENGTH) { return null; } let distanceCycle = data.readUInt8(V3_1E_DISTANCE_CYCLE_OFFSET); let distance = data.readUInt16LE(V3_1E_DISTANCE_OFFSET) / 1000; let flags = data.readUInt8(V3_1E_FLAGS_OFFSET); let isBatteryLow = ((flags & V3_1E_LOW_BATTERY_MASK) !== 0); let isOccupancyDetected = [ (flags & V3_1E_OCCUPANCY_DETECTION_MASK) !== 0 ]; let isMotionDetected = [ (flags & V3_1E_MOTION_DETECTION_MASK) !== 0 ]; let isTamperDetected = [ (flags & V3_1E_CONTACT_MASK) !== 0 ]; let isOccupancyDetectedCycle = data.readUInt8( V3_1E_OCCUPANCY_DETECTION_CYCLE_OFFSET); let isTamperDetectedCycle = data.readUInt8(V3_1E_TAMPER_CYCLE_OFFSET); let salt = data.readUInt16BE(V3_1E_SALT_OFFSET); let checksum = data.readUInt16BE(V3_1E_DIGITAL_SIGNATURE_OFFSET); let sensorData = { distance: distance, distanceCycle: distanceCycle, encrypted: { salt: salt, checksum: checksum, method: "minew-connect-v3" }, isBatteryLow: isBatteryLow, isTamperDetected: isTamperDetected, isMotionDetected: isMotionDetected, isOccupancyDetected: isOccupancyDetected, isOccupancyDetectedCycle: isOccupancyDetectedCycle, isTamperDetectedCycle: isTamperDetectedCycle, uri: MINEW_URI }; return sensorData; } /** * Process Minew Connect V3 combination block type 0x03 acceleration data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processV3CombinationBlock03(data) { return { acceleration: [ utils.parseSigned88(data.subarray(0, 2)), utils.parseSigned88(data.subarray(2, 4)), utils.parseSigned88(data.subarray(4, 6)) ] }; } /** * Process Minew Connect V3 combination block type 0x1f door contact data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processV3CombinationBlock1F(data) { let doorSensorStatus = data.readUInt8(); return { isContactDetected: [ (doorSensorStatus & 0x80) === 0x00 ], isTamperDetected: [ (doorSensorStatus & 0x40) === 0x40 ], isContactDetectedCycle: data.readUInt8(1), isTamperDetectedCycle: data.readUInt8(3) }; } /** * Process Minew Connect V3 combination block type 0x21 battery data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processV3CombinationBlock21(data) { return { batteryPercentage: data.readUInt8() }; } /** * Process Minew Connect V3 combination block type 0x22 tamper data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processV3CombinationBlock22(data) { let isTriggered = (data.readUInt8() > 0); return { isContactDetected: [ !isTriggered ] }; } /** * Process Minew Connect V3 blocks data. * @param {Object} data The raw V3 blocks data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processV3SensorBlocks(data) { let sensorData = { uri: MINEW_URI }; let currentBlockOffset = 0; while(currentBlockOffset < data.length) { let currentBlockId = data.readUInt8(currentBlockOffset); if(V3_SENSOR_BLOCKS.has(currentBlockId)) { let currentBlockParameters = V3_SENSOR_BLOCKS.get(currentBlockId); let currentBlockLength = currentBlockParameters.length; let currentBlock = data.subarray(currentBlockOffset + 1, currentBlockOffset + currentBlockLength); let blockData = currentBlockParameters.process(currentBlock); Object.assign(sensorData, blockData); currentBlockOffset += currentBlockLength; } else { return sensorData; } } return sensorData; } /** * Process Minew Connect V3 sensor block type 0x01 temperature & humidity data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processV3SensorBlock01(data) { let dataFormat = data.readUInt8(); if(dataFormat === 0x01) { let temperature = (175 * data.readUInt16BE(1) / 65535) - 45; let relativeHumidity = (125 * data.readUInt16BE(3) / 65535) - 6; return { temperature: temperature, relativeHumidity: relativeHumidity }; } return {}; } /** * Process Minew Connect V3 sensor block type 0x02 MAC data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processV3SensorBlock02(data) { return { deviceIds: [ toMacSignature(data, 0) ] }; } /** * Process Minew Connect V3 sensor block type 0x03 battery data. * @param {Object} data The raw manufacturer data as a Buffer. * @return {Object} The processed beacon data as JSON. */ function processV3SensorBlock03(data) { return { batteryVoltage: data.readUInt16BE() / 1000 }; } /** * Convert the given 48-bit data into a MAC address signature. * @param {Buffer} data The buffer containing the little-endian MAC address. * @param {Integer} index The index to the start of the MAC address. * @return {String} The MAC address signature. */ function toMacSignature(data, index) { return data.toString('hex', index + 5, index + 6) + data.toString('hex', index + 4, index + 5) + data.toString('hex', index + 3, index + 4) + data.toString('hex', index + 2, index + 3) + data.toString('hex', index + 1, index + 2) + data.toString('hex', index + 0, index + 1) + '/' + IDENTIFIER_TYPE_EUI_48; } module.exports.process = process;