UNPKG

bt-sensors-plugin-sk

Version:

Bluetooth Sensors for Signalk -- support for Victron devices, RuuviTag, Xiaomi, ATC and Inkbird, Ultrasonic wind meters, Mopeka tank readers, Renogy Battery and Solar Controllers, Aranet4 environment sensors, SwitchBot temp and humidity sensors, Kilovault

595 lines (517 loc) 18.2 kB
const fs = require('fs') const util = require('util') const path = require('path') const semver = require('semver') const packageInfo = require("./package.json") const {createBluetooth} = require('node-ble') const { Variant } = require('dbus-next') const {bluetooth, destroy} = createBluetooth() const BTSensor = require('./BTSensor.js') const BLACKLISTED = require('./sensor_classes/BlackListedDevice.js') class MissingSensor { constructor(config){ this.Metadatum = BTSensor.Metadatum this.addMetadatum=BTSensor.prototype.addMetadatum this.getPathMetadata = BTSensor.prototype.getPathMetadata this.getParamMetadata = BTSensor.prototype.getParamMetadata this.metadata= new Map() var keys = Object.keys(config?.paths??{}) this.addMetadatum.bind(this) keys.forEach((key)=>{ this.addMetadatum(key, config.paths[key]?.type??'string', config.paths[key].description ) } ) keys = Object.keys(config?.params??{}) keys.forEach((key)=>{ this.addMetadatum(key, config.params[key]?.type??'string', config.params[key].description ).isParam=true this[key]=config.params[key] }) this.mac_address = config.mac_address } hasGATT(){ return false } getMetadata(){ return this.metadata } getMacAddress(){ return this.mac_address } getDescription(){ return "" } getName(){ return this?.name??"Unknown device" } getDisplayName(){ return `OUT OF RANGE DEVICE (${this.getName()} ${this.getMacAddress()})` } stopListening(){} listen(){} } module.exports = function (app) { var adapterID = 'hci0' var deviceConfigs var starts=0 var classMap var utilities_sk var plugin = {}; plugin.id = 'bt-sensors-plugin-sk'; plugin.name = 'BT Sensors plugin'; plugin.description = 'Plugin to communicate with and update paths to BLE Sensors in Signalk'; //Try and load utilities-sk NOTE: should be installed from App Store-- //But there's a fail safe because I'm a reasonable man. utilities_sk = { loadClasses: function(dir, ext='.js') { const classMap = new Map() const classFiles = fs.readdirSync(dir) .filter(file => file.endsWith(ext)); classFiles.forEach(file => { const filePath = path.join(dir, file); const cls = require(filePath); classMap.set(cls.name, cls); }) return classMap } } function sleep(x) { return new Promise((resolve) => { setTimeout(() => { resolve(x); }, x); }); } async function instantiateSensor(device,config){ try{ for (var [clsName, cls] of classMap) { if (clsName.startsWith("_")) continue const c = await cls.identify(device) if (c) { c.debug=app.debug const sensor = new c(device,config?.params, config?.gattParams) sensor.debug=app.debug await sensor.init() app.debug(`instantiated ${await BTSensor.getDeviceProp(device,"Address")}`) return sensor } }} catch(error){ const msg = `Unable to instantiate ${await BTSensor.getDeviceProp(device,"Address")}: ${error.message} ` app.debug(msg) app.debug(error) app.setPluginError(msg) } //if we're here ain't got no class for the device var sensor if (config.params?.sensorClass){ const c = classMap.get(config.params.sensorClass) c.debug=app.debug sensor = new c(device,config?.params, config?.gattParams) sensor.debug=app.debug await sensor.init() } else{ sensor = new (classMap.get('UNKNOWN'))(device) await sensor.init() } return sensor } function createPaths(config){ config.sensor.getMetadata().forEach((metadatum, tag)=>{ if ((!(metadatum?.isParam)??false)){ //param metadata is passed to the sensor at //create time through the constructor, and isn't a //a value you want to see in a path const path = config.paths[tag] if (!(path===undefined)) app.handleMessage(plugin.id, { updates: [{ meta: [{path: path, value: { units: metadatum?.unit }}]}] }) } }) } function initPaths(deviceConfig){ deviceConfig.sensor.getMetadata().forEach((metadatum, tag)=>{ const path = deviceConfig.paths[tag]; if (!(path === undefined)) deviceConfig.sensor.on(tag, (val)=>{ if (metadatum.notify){ app.notify( tag, val, plugin.id ) } else { updatePath(path,val) } }) }) } function updatePath(path, val){ app.handleMessage(plugin.id, {updates: [ { values: [ {path: path, value: val }] } ] }) } function loadClassMap() { const _classMap = utilities_sk.loadClasses(path.join(__dirname, 'sensor_classes')) classMap = new Map([..._classMap].filter(([k, v]) => !k.startsWith("_") )) const libPath = app.config.appPath +( semver.gt(app.config.version,"2.13.5")?"dist":"lib" ) //+ app.config.version import(libPath+"/modules.js").then( (modulesjs)=>{ const { default:defaultExport} = modulesjs const modules = defaultExport.modulesWithKeyword(app.config, "signalk-bt-sensor-class") modules.forEach((module)=>{ module.metadata.classFiles.forEach((classFile)=>{ const cls = require(module.location+module.module+"/"+classFile); classMap.set(cls.name, cls); }) }) classMap.get('UNKNOWN').classMap=new Map([...classMap].sort().filter(([k, v]) => !v.isSystem )) // share the classMap with Unknown for configuration purposes }) } app.debug(`Loading plugin ${packageInfo.version}`) plugin.schema = { type: "object", description: "NOTE: \n 1) Plugin must be enabled to configure your sensors. \n"+ "2) You will have to wait until the scanner has found your device before seeing your device's config fields and saving the configuration. \n"+ "3) To refresh the list of available devices and their configurations, just open and close the config screen by clicking on the arrow symbol in the config's top bar. \n"+ "4) If you submit and get errors it may be because the configured devices have not yet all been discovered.", required:["adapter","discoveryTimeout", "discoveryInterval"], properties: { adapter: {title: "Bluetooth adapter", type: "string", default: "hci0"}, transport: {title: "Transport ", type: "string", enum: ["auto","le","bredr"], default: "le", enumNames:["Auto", "LE-Bluetooth Low Energy", "BR/EDR Bluetooth basic rate/enhanced data rate"]}, discoveryTimeout: {title: "Default device discovery timeout (in seconds)", type: "integer", default: 30, minimum: 10, maximum: 3600 }, discoveryInterval: {title: "Scan for new devices interval (in seconds-- 0 for no new device scanning)", type: "integer", default: 10, minimum: 0, multipleOf: 10 }, peripherals: { type: "array", title: "Sensors", items:{ title: "", type: "object", required:["mac_address", "discoveryTimeout"], properties:{ active: {title: "Active", type: "boolean", default: true }, mac_address: {title: "Bluetooth Sensor", type: "string" }, discoveryTimeout: {title: "Device discovery timeout (in seconds)", type: "integer", default:30, minimum: 10, maximum: 600 } } } } } } const UI_SCHEMA = { "peripherals": { 'ui:disabled': !plugin.started } } plugin.uiSchema=UI_SCHEMA function updateSensorDisplayName(sensor){ const mac_address = sensor.getMacAddress() const displayName = sensor.getDisplayName() const mac_addresses_list = plugin.schema.properties.peripherals.items.properties. mac_address.enum const mac_addresses_names = plugin.schema.properties.peripherals.items.properties. mac_address.enumNames var index = mac_addresses_list.indexOf(mac_address) if (index!=-1) mac_addresses_names[index]= displayName } function removeSensorFromList(sensor){ const mac_addresses_list = plugin.schema.properties.peripherals.items.properties.mac_address.enum const mac_addresses_names = plugin.schema.properties.peripherals.items.properties.mac_address.enumNames const oneOf = plugin.schema.properties.peripherals.items.dependencies.mac_address.oneOf const mac_address = sensor.getMacAddress() const i = mac_addresses_list.indexOf(mac_address) if (i<0) return // n'existe pas mac_addresses_list.splice(i,1) mac_addresses_names.splice(i,1) oneOf.splice(oneOf.findIndex((p)=>p.properties.mac_address.enum[0]==mac_address),1) } function addSensorToList(sensor){ if (!plugin.schema.properties.peripherals.items.dependencies) plugin.schema.properties.peripherals.items.dependencies={mac_address:{oneOf:[]}} if(plugin.schema.properties.peripherals.items.properties.mac_address.enum==undefined) { plugin.schema.properties.peripherals.items.properties.mac_address.enum=[] plugin.schema.properties.peripherals.items.properties.mac_address.enumNames=[] } const mac_addresses_names = plugin.schema.properties.peripherals.items.properties.mac_address.enumNames const mac_addresses_list = plugin.schema.properties.peripherals.items.properties.mac_address.enum const mac_address = sensor.getMacAddress() const displayName = sensor.getDisplayName() var index = mac_addresses_list.indexOf(mac_address) if (index==-1) index = mac_addresses_list.push(mac_address)-1 mac_addresses_names[index]= displayName var oneOf = {properties:{mac_address:{enum:[mac_address]}}} oneOf.properties.params={ title:`Device parameters`, description: sensor.getDescription(), type:"object", properties:{} } sensor.getParamMetadata().forEach((metadatum,tag)=>{ oneOf.properties.params.properties[tag]=metadatum.asJSONSchema() }) if (sensor.hasGATT()){ oneOf.properties.gattParams={ title:`GATT Specific device parameters`, description: sensor.getGATTDescription(), type:"object", properties:{} } sensor.getGATTParamMetadata().forEach((metadatum,tag)=>{ oneOf.properties.gattParams.properties[tag]=metadatum.asJSONSchema() }) } oneOf.properties.paths={ title:"Signalk Paths", description: `Signalk paths to be updated when ${sensor.getName()}'s values change`, type:"object", properties:{} } sensor.getPathMetadata().forEach((metadatum,tag)=>{ oneOf.properties.paths.properties[tag]=metadatum.asJSONSchema() }) plugin.schema.properties.peripherals.items.dependencies.mac_address.oneOf.push(oneOf) //plugin.schema.properties.peripherals.items.title=sensor.getName() } function deviceNameAndAddress(config){ return `${config?.name??""}${config.name?" at ":""}${config.mac_address}` } async function createSensor(adapter, config) { return new Promise( ( resolve, reject )=>{ var s app.debug(`Waiting on ${deviceNameAndAddress(config)}`) adapter.waitDevice(config.mac_address,config.discoveryTimeout*1000) .then(async (device)=> { app.debug(`Found ${config.mac_address}`) s = await instantiateSensor(device,config) sensorMap.set(config.mac_address,s) if (s instanceof BLACKLISTED) reject ( `Device is blacklisted (${s.reasonForBlacklisting()}).`) else{ addSensorToList(s) s.on("RSSI",(()=>{ updateSensorDisplayName(s) })) resolve(s) } }) .catch((e)=>{ if (s) s.stopListening() app.debug(`Unable to communicate with device ${deviceNameAndAddress(config)} Reason: ${e?.message??e}`) app.debug(e) reject( e?.message??e ) })}) } async function startScanner(transport) { app.debug("Starting scan..."); //Use adapter.helper directly to get around Adapter::startDiscovery() //filter options which can cause issues with Device::Connect() //turning off Discovery //try {await adapter.startDiscovery()} try{ if (transport) { app.debug(`Setting Bluetooth transport option to ${transport}`) await adapter.helper.callMethod('SetDiscoveryFilter', { Transport: new Variant('s', transport) }) } await adapter.helper.callMethod('StartDiscovery') } catch (error){ app.debug(error) } } const sensorMap=new Map() plugin.started=false loadClassMap() var discoveryIntervalID var adapter var adapterPower plugin.start = async function (options, restartPlugin) { plugin.started=true var adapterID=options.adapter function getDeviceConfig(mac){ return deviceConfigs.find((p)=>p.mac_address==mac) } function initConfiguredDevice(deviceConfig){ app.setPluginStatus(`Initializing ${deviceNameAndAddress(deviceConfig)}`); if (!deviceConfig.discoveryTimeout) deviceConfig.discoveryTimeout = options.discoveryTimeout createSensor(adapter, deviceConfig).then((sensor)=>{ deviceConfig.sensor=sensor if (deviceConfig.active) { if (deviceConfig.paths){ createPaths(deviceConfig) initPaths(deviceConfig) } const result = Promise.resolve(deviceConfig.sensor.listen()) result.then(() => { app.debug(`Listening for changes from ${deviceConfig.sensor.getDisplayName()}`); app.setPluginStatus(`Initial scan complete. Listening to ${++found} sensors.`); }) } }) .catch((error)=> { const msg =`Sensor at ${deviceConfig.mac_address} unavailable. Reason: ${error}` app.debug(msg) app.debug(error) if (deviceConfig.active) app.setPluginError(msg) deviceConfig.sensor=new MissingSensor(deviceConfig) addSensorToList(deviceConfig.sensor) //add sensor to list with known options }) } function findDevices (discoveryTimeout) { app.setPluginStatus("Scanning for new Bluetooth devices..."); adapter.devices().then( (macs)=>{ for (var mac of macs) { const deviceConfig = getDeviceConfig(mac) const _mac = mac if (deviceConfig && deviceConfig.sensor instanceof MissingSensor){ removeSensorFromList(deviceConfig.sensor) initConfiguredDevice(deviceConfig) } else { if (!sensorMap.has(_mac)) { if (deviceConfig) continue; createSensor(adapter, {mac_address:_mac, discoveryTimeout: discoveryTimeout*1000}) .then((s)=> app.setPluginStatus(`Found ${s.getDisplayName()}.`)) .catch((e)=> app.debug(`Device at ${_mac} unavailable. Reason: ${e}`)) } } } }) } function findDeviceLoop(discoveryTimeout, discoveryInterval, immediate=true ){ if (immediate) findDevices(discoveryTimeout) discoveryIntervalID = setInterval( findDevices, discoveryInterval*1000, discoveryTimeout) } if (!adapterID || adapterID=="") adapterID = "hci0" //Check if Adapter has changed since last start() if (adapter) { const n = await adapter.getName() if (n!=adapterID) { adapter.helper.removeAllListeners() adapter=null } } //Connect to adapter if (!adapter){ app.debug(`Connecting to bluetooth adapter ${adapterID}`); adapter = await bluetooth.getAdapter(adapterID) //Set up DBUS listener to monitor Powered status of current adapter await adapter.helper._prepare() adapter.helper._propsProxy.on('PropertiesChanged', async (iface,changedProps,invalidated) => { if (Object.hasOwn(changedProps,"Powered")){ if (changedProps.Powered.value==false) { if (plugin.started){ //only call stop() if plugin is started app.setPluginStatus(`Bluetooth Adapter ${adapterID} turned off. Plugin disabled.`) await plugin.stop() adapterPower=false } } else { if (!adapterPower) { //only call start() once adapterPower=true await plugin.start(options,restartPlugin) } } } }) if (!await adapter.isPowered()) { app.debug(`Bluetooth Adapter ${adapterID} not powered on.`) app.setPluginError(`Bluetooth Adapter ${adapterID} not powered on.`) adapterPower=false await plugin.stop() return } } adapterPower=true plugin.uiSchema.peripherals['ui:disabled']=false sensorMap.clear() deviceConfigs=options?.peripherals??[] if (plugin.stopped) { await sleep(5000) //Make sure plugin.stop() completes first //plugin.start is called asynchronously for some reason //and does not wait for plugin.stop to complete plugin.stopped=false } const activeAdapters = await bluetooth.activeAdapters() plugin.schema.properties.adapter.enum=[] plugin.schema.properties.adapter.enumNames=[] for (a of activeAdapters){ plugin.schema.properties.adapter.enum.push(a.adapter) plugin.schema.properties.adapter.enumNames.push(`${a.adapter} @ ${ await a.getAddress()} (${await a.getName()})`) } plugin.uiSchema.adapter={'ui:disabled': (activeAdapters.length==1)} await startScanner(options.transport) if (starts>0){ app.debug(`Plugin ${packageInfo.version} restarting...`); if (plugin.schema.properties.peripherals.items.dependencies) plugin.schema.properties.peripherals.items.dependencies.mac_address.oneOf=[] } else { app.debug(`Plugin ${packageInfo.version} started` ) } starts++ if (!await adapter.isDiscovering()) try{ await adapter.startDiscovery() } catch (e){ app.debug(`Error starting scan: ${e.message}`) } if (!(deviceConfigs===undefined)){ var found = 0 for (const deviceConfig of deviceConfigs) { initConfiguredDevice(deviceConfig) } } if (options.discoveryInterval && !discoveryIntervalID) findDeviceLoop(options.discoveryTimeout, options.discoveryInterval) } plugin.stop = async function () { app.debug("Stopping plugin") plugin.stopped=true plugin.started=false plugin.uiSchema.peripherals['ui:disabled']=true if ((sensorMap)){ plugin.schema.properties.peripherals.items.properties.mac_address.enum=[] plugin.schema.properties.peripherals.items.properties.mac_address.enumNames=[] sensorMap.forEach(async (sensor, mac)=> { try{ await sensor.stopListening() app.debug(`No longer listening to ${mac}`) } catch (e){ app.debug(`Error stopping listening to ${mac}: ${e.message}`) } }) } if (discoveryIntervalID) { clearInterval(discoveryIntervalID) discoveryIntervalID=null } if (adapter && await adapter.isDiscovering()) try{ await adapter.stopDiscovery() app.debug('Scan stopped') } catch (e){ app.debug(`Error stopping scan: ${e.message}`) } app.debug('BT Sensors plugin stopped') } return plugin; }