diff --git a/ble-pasv-mqtt-gw.js b/ble-pasv-mqtt-gw.js index a0cc97a..5639bec 100644 --- a/ble-pasv-mqtt-gw.js +++ b/ble-pasv-mqtt-gw.js @@ -1,52 +1,135 @@ /** - * BLE passive temperature sensor scanner and MQTT gateway - * Detected devices will be automatically registered to HA/Domoticz MQTT Autodiscovery + * BLE passive scanner and MQTT gateway + * Detected devices will be automatically registered to HA/Domoticz using MQTT Autodiscovery. + * + * Quick instructions: + * 1. Run the scripts with default settings (debug enabled, filtering enabled) + * 2. Open script to see debug console + * 2. Press button of device you want to add or wait for it airs anything + * 3. Copy mac address from the debug console + * 4. Add mac to `allowed_devices` variable or `allowed_devices` KVS key. Read below for the data format + * 5. Restart script + * 6. Read debug for discovered device name. + * 7. Look for the device in HA + * + * More information: + * + * Because BLE passive scanner process data of all surroudning BLE devices, this script allows to filter them by MAC address. + * + * IF `CONFIG.filter_devices` IS `true` (default), THEN + * ONLY BLE DEVICES IDENTIFIED BY MAC ADDRESSES FOUND IN `allowed_devicess` STRUCTURE WILL BE PROCESSED + * + * MAC addresses can be set in `CONFIG.allowed_devicess` variable or into KVS under `allowed_devicess` key. + * The KVS allows to add devices without need of changing the code of the script. + + * The format of those data is: { "": [ "", "" ], } for example: + * + * { + * "xxxxxxxxxxxx": [ "Shelly", "DW BLU" ], + * "yyyyyyyyyyyy": [ "Shelly", "Motion BLU" ], + * "zzzzzzzzzzzz": [ "Shelly", "H&T BLU" ] + * } + * + * The manufacturer and model cannot be obtained from passive scan of BLE telegrams. + * Set into structure, contribute to MQTT dicovery. + * If you don't need that (not adviced), pass empty array instead: { "3c2exxxxxxxx": [], } + * + * Debugging: + * + * You can disable mac filtering at all by setting `CONFIG.filter_devices` to false. + * + * If `CONFIG.debug` is true, script will output mac addresses of ignored and processed devices to the console. + * Also it will output device and entity names, though once at time of registering them to MQTT Discovery. * * Supported payloads: ATC/Xiaomi/BTHomev2 through advertisements packets * * Sensor values sent to 'mqtt_topic' and device config objects sent to 'discovery_topic'. - * Copyleft Alexander Nagy @ bitekmindenhol.blog.hu + * Copyleft Alexander Nagy @ bitekmindenhol.blog.hu, Michal Bartak */ + +const DEVICE_INFO = Shelly.getDeviceInfo(); + let CONFIG = { + /** + * If true, only selected devices will be processed. + * Selection of devices is achieved by setting up `CONFIG.allowed_devices` or (preferably) storing that information in KVS store under `allowed_devices` key. + * + * If false (not recommended), no filtering is applied, and all BLE messages are intercepted and passed to MQTT. + */ + filter_devices: true, + + /** + * If true, it will output: + * * MAC address of the ignored device + * * MAC address for processed device + * * If the device is discovered the first time, names of device and entities (to find them easier in HA)) + */ + debug: true, + + /** + * Structure providing devices to be processed. + * + * Data might also be stored in KVS under `allowed_devices` key (the same structure). It will be merged data in the `CONFIG.allowed_devices` variable.\ + * The script needs to be restarted to load changes from KVS. + * + * Examples: + * * `{ "macxxxxxxxxx": [ "Shelly", "DW BLU" ], }` + * * `{ "macyyyyyyyyy": [] }` + * Model and Manufacturer are optional. But then won't be reported to MQTT Discovery (model takes also a part in device name) + */ + allowed_devices: {}, + + /** + * value added payload stored into mqtt_topic. Indicates a device which has stored the data (device the script is running on). + * Set to null to disable reporting. + */ + mqtt_src: DEVICE_INFO.id + " (" + DEVICE_INFO.name + ")", + + /** + * The KVS key, a user can set up allowed devices. + * + * Data has to be prepared as JSON. For structure, refer `CONFIG.allowed_devicess` description + */ + kvs_key: "allowed_devices", mqtt_topic: "blegateway/", - // if mqtt_src is defined, there will be a src field with this value in every mqtt message to identify the shelly which created this message. ie. "shelly-123456" - mqtt_src: null, discovery_topic: "homeassistant/", + }; const SCAN_PARAM_WANT = { duration_ms: BLE.Scanner.INFINITE_SCAN, active: false } + //BTHomev2: ID , Size, Sign, Factor, Name let datatypes = [ - [0x00, 1, false, 1, 'pid'], - [0x01, 1, false, 1, 'battery'], - [0x12, 2, false, 1, 'co2'], - [0x0c, 2, false, 0.001,'voltage'], - [0x4a, 2, false, 0.1, 'voltage'], - [0x08, 2, true, 0.01, 'dewpoint'], - [0x03, 2, false, 0.01, 'humidity'], - [0x2e, 1, false, 1, 'humidity'], - [0x05, 3, false, 0.01, 'illuminance'], - [0x14, 2, false, 0.01, 'moisture'], - [0x2f, 1, false, 1, 'moisture'], - [0x04, 3, false, 0.01, 'pressure'], - [0x45, 2, true, 0.1, 'temperature'], - [0x02, 2, true, 0.01, 'temperature'], - [0x3f, 2, true, 0.1, 'rotation'], - [0x3a, 1, false, 1, 'button'], //selector - [0x15, 1, false, 1, 'battery_ok'], //binary - [0x16, 1, false, 1, 'battery_charging'], //binary - [0x17, 1, false, 1, 'co'], //binary - [0x18, 1, false, 1, 'cold'], //binary - [0x1a, 1, false, 1, 'door'], //binary - [0x1b, 1, false, 1, 'garage_door'], //binary - [0x1c, 1, false, 1, 'gas'], //binary - [0x1d, 1, false, 1, 'heat'], //binary - [0x1e, 1, false, 1, 'light'], //binary - [0x1f, 1, false, 1, 'lock'], //binary - [0x20, 1, false, 1, 'moisture_warn'], //binary - [0x21, 1, false, 1, 'motion'], //binary - [0x2d, 1, false, 1, 'window'], //binary + [0x00, 1, false, 1, 'pid'], + [0x01, 1, false, 1, 'battery'], + [0x12, 2, false, 1, 'co2'], + [0x0c, 2, false, 0.001,'voltage'], + [0x4a, 2, false, 0.1, 'voltage'], + [0x08, 2, true, 0.01, 'dewpoint'], + [0x03, 2, false, 0.01, 'humidity'], + [0x2e, 1, false, 1, 'humidity'], + [0x05, 3, false, 0.01, 'illuminance'], + [0x14, 2, false, 0.01, 'moisture'], + [0x2f, 1, false, 1, 'moisture'], + [0x04, 3, false, 0.01, 'pressure'], + [0x45, 2, true, 0.1, 'temperature'], + [0x02, 2, true, 0.01, 'temperature'], + [0x3f, 2, true, 0.1, 'rotation'], + [0x3a, 1, false, 1, 'button'], //selector + [0x15, 1, false, 1, 'battery_ok'], //binary + [0x16, 1, false, 1, 'battery_charging'], //binary + [0x17, 1, false, 1, 'co'], //binary + [0x18, 1, false, 1, 'cold'], //binary + [0x1a, 1, false, 1, 'door'], //binary + [0x1b, 1, false, 1, 'garage_door'], //binary + [0x1c, 1, false, 1, 'gas'], //binary + [0x1d, 1, false, 1, 'heat'], //binary + [0x1e, 1, false, 1, 'light'], //binary + [0x1f, 1, false, 1, 'lock'], //binary + [0x20, 1, false, 1, 'moisture_warn'], //binary + [0x21, 1, false, 1, 'motion'], //binary + [0x2d, 1, false, 1, 'window'], //binary ]; let discovered = []; @@ -137,142 +220,390 @@ let packedStruct = { }; function convertByteArrayToSignedInt(bytes, byteSize) { - let result = 0; - const signBit = 1 << (byteSize * 8 - 1); - for (let i = 0; i < byteSize; i++) { - result |= (bytes.at(i) << (i * 8)); - } - // Check sign bit and sign-extend if needed - if ((result & signBit) !== 0) { - result = result - (1 << (byteSize * 8)); - } - return result; + let result = 0; + const signBit = 1 << (byteSize * 8 - 1); + for (let i = 0; i < byteSize; i++) { + result |= (bytes.at(i) << (i * 8)); + } + // Check sign bit and sign-extend if needed + if ((result & signBit) !== 0) { + result = result - (1 << (byteSize * 8)); + } + return result; }; function convertByteArrayToUnsignedInt(bytes, byteSize) { - let result = 0; - for (let i = 0; i < byteSize; i++) { - result |= (bytes.at(i) << (i * 8)); - } - return result >>> 0; // Ensure the result is an unsigned integer + let result = 0; + for (let i = 0; i < byteSize; i++) { + result |= (bytes.at(i) << (i * 8)); + } + return result >>> 0; // Ensure the result is an unsigned integer }; function extractBTHomeData(payload) { - let index = 0; - let extractedData = {}; - while (index < payload.length) { - dataId = payload.at(index); - index = index + 1; - let dataType = -1; - for (let i=0; i-1) { - let byteSize = datatypes[dataType][1]; - let factor = datatypes[dataType][3]; - let rawdata = payload.slice(index, index + byteSize); - if (datatypes[dataType][2]) { - value = convertByteArrayToSignedInt(rawdata, byteSize); - } else { - value = convertByteArrayToUnsignedInt(rawdata, byteSize); - } - extractedData[ datatypes[dataType][4] ] = value * factor; - index += byteSize; - } else { index = 10;} + let index = 0; + let extractedData = {}; + var button_ordinal = 0; + while (index < payload.length) { + dataId = payload.at(index); + index = index + 1; + let dataType = -1; + for (let i = 0; i < datatypes.length; i++) { + if (datatypes[i][0] == dataId) { + dataType = i; + break; + } } + if (dataType > -1) { + let byteSize = datatypes[dataType][1]; + let factor = datatypes[dataType][3]; + let rawdata = payload.slice(index, index + byteSize); + if (datatypes[dataType][2]) { + value = convertByteArrayToSignedInt(rawdata, byteSize); + } else { + value = convertByteArrayToUnsignedInt(rawdata, byteSize); + } - return extractedData; + // buttons data, expected in fixed order one after another + if (datatypes[dataType][0] == 0x3a) { + + if (!extractedData[datatypes[dataType][4]]) { + extractedData[datatypes[dataType][4]] = []; + } + + extractedData[datatypes[dataType][4]][button_ordinal] = convertIntToButtonEvent(value * factor); + button_ordinal++; + } else { + extractedData[datatypes[dataType][4]] = value * factor; + } + + index += byteSize; + } else { index = 10; } + } + + return extractedData; }; -function gettopicname(resarray) { - let resstr = ""; - let rlen = Object.keys(resarray).length; - if (rlen>0) { - if (rlen==1) { - resstr = Object.keys(resarray)[0]; - } else if (("temperature" in resarray) || ("humidity" in resarray) || ("pressure" in resarray)) { - resstr = "sensor"; - } else if ("battery" in resarray) { - resstr = "telemetry"; - } else { - resstr = "status"; - } - } - return resstr; +/** + * Converts integer value of button event to textual representation + * @param {integer} intval + * @returns {string} + */ +function convertIntToButtonEvent(intval) { + switch (intval) { + case 0x00: + return 'none'; + case 0x01: + return 'press'; + case 0x02: + return 'double_press'; + case 0x03: + return 'triple_press'; + case 0x04: + return 'long_press'; + case 0x80: + return 'hold'; + case 0xFE: + return 'hold'; + default: + return 'unsupported: ' + intval; + } } -function autodiscovery(address, topname, topic, jsonstr) { - let adstr = [ ]; - let params = Object.keys(jsonstr); - let subt = ""; - for (let i = 0; i} resarray Array of data + * @returns {string} name of an MQTT topic to store data to + */ +function getTopicName(resarray) { + let rlen = Object.keys(resarray).length; + if (rlen == 0) return ""; + if (rlen == 1) return Object.keys(resarray)[0]; + return "data"; +} + +/** + * Function creates and returns a device data in format requierd by MQTT discovery. + * + * Manufacturer and model are retrieved from CONFIG.allowedMACs global variable. + * Device name is built from its mac address and model name (if exists) + * via_device is set to Shelly address the script is run on. + * + * @param address {string} - normalized already mac address of the BLE device. + * @returns {} device object structured for MQTT discovery + */ +function discoveryDevice(address) { + + let model = ""; + + if (CONFIG.allowed_devices[address][1] !== undefined) { + model = CONFIG.allowed_devices[address][1]; + } + + device = {}; + device["name"] = address + (model === "" ? "" : "-" + model); + device["ids"] = [address + ""]; + device["cns"] = [["mac", address]]; + device["via_device"] = normalizeMacAddress(DEVICE_INFO.mac); + + if (CONFIG.allowed_devices[address][0] !== undefined) { + device["mf"] = CONFIG.allowed_devices[address][0]; + } + + if (model !== "") { + device["mdl"] = model; + } + + printDebug("Device name: ", device["name"]); + + return device; +} + +/** + * Cretes and publishes discovery topic for single entity + * @param {string} objident Object identifier used for preventing repeating discovery topic creation. Will be returned back in the result struct + * @param {string} topic MQTT topic where data are reported to. Needed to include into Discovery definition + * @param {string} objtype Name of object type. Mostly it will be borrowed for entity name + * @param {integer} bt_index 0-based index of button. Used to identify a button being a member of a multi-button device + * @return {} Object with data for publishing to MQTT + */ +function discoveryEntity(objident, topic, objtype, bt_index) { + + let pload = {}; + + // Some defaults. Might be overriden later + pload["name"] = objtype; + pload["uniq_id"] = objident; + pload["stat_t"] = topic; + pload["val_tpl"] = "{{ value_json." + objtype + " }}"; + + let subt = ""; + let domain; + + switch (objtype) { + + /* SENSORS */ + + case "temperature": + case "humidity": + domain = "sensor"; + pload["dev_cla"] = objtype; + pload["stat_cla"] = "measurement" + pload["unit_of_meas"] = objtype === "temperature" ? "°C" : "%"; + subt = objtype; + break; + + case "battery": + domain = "sensor"; + pload["dev_cla"] = "battery"; + pload["stat_cla"] = "measurement" + pload["ent_cat"] = "diagnostic"; + pload["unit_of_meas"] = "%"; + subt = "battery"; + break; + + case "illuminance": + domain = "sensor"; + pload["dev_cla"] = "illuminance"; + pload["stat_cla"] = "measurement" + pload["unit_of_meas"] = "lx"; + subt = "illuminance"; + break; + + case "pressure": + domain = "sensor"; + pload["dev_cla"] = "atmospheric_pressure"; + pload["stat_cla"] = "measurement" + pload["unit_of_meas"] = "hPa"; + subt = "atmospheric_pressure"; + break; + + case "rssi": + domain = "sensor"; + pload["dev_cla"] = "signal_strength"; + pload["stat_cla"] = "measurement" + pload["ent_cat"] = "diagnostic"; + pload["unit_of_meas"] = "dBm"; + subt = "rssi"; + break; + + case "rotation": + domain = "sensor"; + pload["name"] = "tilt"; + pload["stat_cla"] = "measurement" + pload["unit_of_meas"] = "°"; + pload["stat_t"] = topic + "/rotation"; + subt = "tilt"; + delete pload.val_tpl; + break; + + /* BINARY SENSORS */ + + case "window": + domain = "binary_sensor"; + pload["name"] = "contact"; + pload["dev_cla"] = "opening"; + pload["pl_on"] = 1; + pload["pl_off"] = 0; + pload["stat_t"] = topic + "/window"; + subt = "opening"; + delete pload.val_tpl; + break; + + case "motion": + domain = "binary_sensor"; + pload["dev_cla"] = "motion"; + pload["pl_on"] = 1; + pload["pl_off"] = 0; + subt = "motion"; + break; + + /* BUTTONS */ + + case "button": + + domain = "event"; + pload["p"] = "event"; + pload["dev_cla"] = "button"; + pload["evt_typ"] = ["none", "press", "double_press", "triple_press", "long_press", "hold"]; + + if (bt_index == -1) { + pload["name"] = "button" + subt = "button"; + } else { + pload["name"] = "button " + (bt_index + 1); + subt = "button-" + (bt_index + 1); + } + + if (bt_index == -1) bt_index = 0; + + pload["stat_t"] = topic; + pload["val_tpl"] = '{% set buttons = value_json.get("button") %} \ +{ {%- if buttons and buttons[' + bt_index + '] != "none" -%} \ +"button": "button", "event_type": "{{ buttons[' + bt_index + '] }}" \ +{%- endif -%} }'; + + break; + default: + printDebug("Unrecognized obj type: ", objtype, ". Ignored"); + return; + break; + } + + return { "objident": objident, "domain": domain, "subtopic": subt, "data": pload } +} + + +/** + * Normalize MAC address removing : and - characters, and making the rest lowercase + * @param {string} address MAC address + * @returns {string} normalized MAC address + */ +function normalizeMacAddress(address) { + return String(address).split("-").join("").split(":").join("").toLowerCase(); +} + +/** + * Determines whethere device identified by the `address` has to be processed or skipped. + * @param {string} address Normalized form of MAC address + * @returns {bool} False if device has to be skipped. Otherwise true. + */ +function allowDevices(address) { + if (!CONFIG.filter_devices) return true; + + // escape if not mac address found + if (CONFIG.allowed_devices[address] === undefined) { + return false; + } + + return true; +} + +/** + * Generate discovery topics when not yet reported + * @param {string} address + * @param {string} topic + * @param {} jsonstr + */ +function discoveryItems(address, topic, jsonstr) { + + let params = Object.keys(jsonstr); + let ploads = []; + + // Iterate through all values, even unsupported. + // Not supported params will be ignored within autodiscovery() + // then stored into `discovered` array and never processed again. + for (let i = 0; i < params.length; i++) { + let objtype = params[i]; + if (objtype == 'button' && jsonstr['button'].length > 1) { // pass only multiple buttons + for (let b = 0; b < jsonstr['button'].length; b++) { + let objident = address + "-" + objtype + "-" + (b + 1); + if (discovered.indexOf(objident) == -1) { + ploads.push(discoveryEntity(objident, topic, objtype, b)); + } + } + } + else { + let objident = address + "-" + objtype; + if (discovered.indexOf(objident) == -1) { + ploads.push(discoveryEntity(objident, topic, objtype, -1)); // we need that -1 in case of single button device (simplifies its name) + } + } + } + + for (let i = 0; i < ploads.length; i++) { + + if (ploads[i] === undefined) continue; + + ploads[i].data.device = discoveryDevice(address); + let discoveryTopic = CONFIG.discovery_topic + ploads[i].domain + "/" + address + "/" + ploads[i].subtopic + "/config"; + + MQTT.publish(discoveryTopic, JSON.stringify(ploads[i].data), 1, true); + printDebug("Discovered: ", ploads[i].data.name, "; path", discoveryTopic); + + discovered.push(ploads[i].objident); //mark as discovered + } } function mqttreport(address, rssi, jsonstr) { - let addrstr = String(address).split(':').join(''); - let topname = gettopicname(jsonstr); - let topic = CONFIG.mqtt_topic + addrstr + "/" + topname; + + let macNormalized = normalizeMacAddress(address); + + if (!allowDevices(macNormalized)) { + printDebug("Ignored MAC: ", macNormalized); + return; + } + + printDebug("Processed MAC: ", macNormalized); + printDebug("Data: ", JSON.stringify(jsonstr)); + + let topic = CONFIG.mqtt_topic + macNormalized + "/" + getTopicName(jsonstr); + printDebug("Topic:", topic); + jsonstr['rssi'] = rssi; if (CONFIG.mqtt_src) { jsonstr['src'] = CONFIG.mqtt_src; } - if (discovered.indexOf(addrstr+topname) == -1) { - let adstr = autodiscovery(addrstr,topname,topic,jsonstr); - if (adstr.length > 0) { - for (let i = 0; i 1) { - MQTT.publish(adstr[i][0],adstr[i][1],1,true); // console.log("AD",i,adstr[i][0],adstr[i][1]); - } - } - } - discovered.push(addrstr+topname); //mark as discovered - } // end AD - MQTT.publish(topic,JSON.stringify(jsonstr),0,false); // console.log(topic,JSON.stringify(jsonstr),1,true); + + // Create discovery entries if needed + discoveryItems(macNormalized, topic, jsonstr); + + MQTT.publish(topic, JSON.stringify(jsonstr), 1, false); + + // Need to store those parameters into separate topics, since they are not reported with every message. + // It renders in unavailable state on HA restart + if (jsonstr.hasOwnProperty('window')) { + MQTT.publish(topic + '/window', JSON.stringify(jsonstr.window), 1, true); + } + + if (jsonstr.hasOwnProperty('rotation')) { + MQTT.publish(topic + '/rotation', JSON.stringify(jsonstr.rotation), 1, true); + } + } function scanCB(ev, res) { @@ -337,11 +668,11 @@ function scanCB(ev, res) { } else if ( (dataType == 7) && (dataSize >= 3) ) { packedStruct.setBuffer(res.advData.slice(18+ofs)); hdr = packedStruct.unpack(' 0) ) { packedStruct.setBuffer(res.advData.slice(18+ofs)); hdr = packedStruct.unpack(' 0 && CONFIG.debug) { + let msg = "Devices configured for processing:\n"; + for (let k in CONFIG.allowed_devices) { + msg = msg + "\n" + "MAC: " + k + + "; Manufacturer: " + (CONFIG.allowed_devices[k][0] === undefined ? "" : CONFIG.allowed_devices[k][0]) + + "; Model: " + (CONFIG.allowed_devices[k][1] === undefined ? "" : CONFIG.allowed_devices[k][1]); + } + + printDebug(msg); + } +}); \ No newline at end of file