UNPKG

bt-sensors-plugin-sk

Version:

Bluetooth Sensors for Signalk - see https://www.npmjs.com/package/bt-sensors-plugin-sk#supported-sensors for a list of supported sensors

1,226 lines (1,038 loc) 38.4 kB
const { Variant } = require('@jellybrick/dbus-next'); const { log } = require('node:console'); const EventEmitter = require('node:events'); const AutoQueue = require("./Queue.js") const DistanceManager = require("./DistanceManager") const OutOfRangeDevice = require("./OutOfRangeDevice.js") /** * @author Andrew Gerngross <oh.that.andy@gmail.com> */ /** * {@link module:node-ble} */ const BTCompanies = require('./bt_co.json'); const connectQueue = new AutoQueue() /** * @global A map of company names keyed by their Bluetooth ID * {@link ./sensor_classes/bt_co.json} file derived from bluetooth-sig source: * {@link https://bitbucket.org/bluetooth-SIG/public/src/main/assigned_numbers/company_identifiers/company_identifiers.yaml} */ const BTCompanyMap=new Map() BTCompanies.company_identifiers.forEach( (v) =>{ BTCompanyMap.set(v.value, v.name) }) /** * @function signalQualityPercentQuad * * Utility to convert RSSI (Bluetooth radio strength signal) * decibel values to a linear percentage * * See {@link https://www.intuitibits.com/2016/03/23/dbm-to-percent-conversion/ } * * */ function signalQualityPercentQuad(rssi, perfect_rssi=-20, worst_rssi=-85) { const nominal_rssi=(perfect_rssi - worst_rssi); var signal_quality = (100 * (perfect_rssi - worst_rssi) * (perfect_rssi - worst_rssi) - (perfect_rssi - rssi) * (15 * (perfect_rssi - worst_rssi) + 62 * (perfect_rssi - rssi))) / ((perfect_rssi - worst_rssi) * (perfect_rssi - worst_rssi)); if (signal_quality > 100) { signal_quality = 100; } else if (signal_quality < 1) { signal_quality = 0; } return Math.ceil(signal_quality); } /** * @classdesc Class that all sensor classes should inherit from. Sensor subclasses * monitor a BT peripheral and emit changes in the sensors's value like "temp" or "humidity" * @class BTSensor * @abstract * @extends EventEmitter * * @requires module:@naugehyde/node-ble/Device * @requires module:@naugehyde/node-ble/BusHelper * */ class BTSensor extends EventEmitter { static DEFAULTS = require('./plugin_defaults.json'); static DistanceManagerSingleton= new DistanceManager({DISTANCE_FIND_LAST_FEW_SAMPLE_TIME_FRAME_MILLIS:60000}) static IsRoaming = false; static SensorDomains={ unknown: { name: "unknown", description: "Unknown sensor domain "}, environmental: { name: "environmental", description: "Sensors that measure environmental conditions - air temperature, humidity etc."}, electrical: { name: "electrical", description: "Electrical sensor - chargers, batteries, inverters etc."}, propulsion: { name: "propulsion", description: "Sensors that measure engine state"}, tanks: { name: "tanks", description: "Sensors that measure level in tanks (gas, propane, water etc.) "}, beacons: { name: "beacons", description: "iBeacon/Eddystone sensor tags"} } static batteryStrengthTag="batteryStrength" static Domain = this.SensorDomains.unknown _currentValues = {} /** * * @param {module:node-ble/Device} device * @param {*} config * @param {*} gattConfig */ constructor(device, config={}, gattConfig={}) { super() this.device=device Object.assign(this,config) Object.assign(this,gattConfig) this._state = "UNKNOWN" } /** * @function _test Test sensor parsing * * @example * * 1) from a command line execute: * bluetoothctl scan on * * 2) from the command line execute: * bluetoothctl devices * until you see your device * * 3) from the command line execute: * bluetoothctl info [your device's mac address] * * 4) copy the sensor's Manufacturer Data or Service Data (whichever advertises your sensor's values) * * 5) format the string of data so it's just the hex value separated by a space * * 6) from the command line execute: * node * * 7) from the node prompt execute: * const MySensorClass = require('./path_to_my_sensor_class/MySensorClass') * * 8) from the node prompt execute: * const myData = <string of data you captured and formatted> * const optionalDecryptionKey = <the encryption key for your sensor> * MySensorClass._test(myData,optionalDecryptionKey) * * You should see your data parsed and formated on the console * If you don't, it's possible your sensor is doing its parsing in the * BTSensor::propertiesChanged function or the encryption key is invalid. * * Errors will occur if the data string is incomplete * * Unusual values are likely to appear if the data string is encrypted * and you didn't provide a decryption key * * @static * @param {string} data string formatted as "AE FF 45..." which is more or less how Bluetoothctl presents it * @param {string|null} key encryption key (optional) * */ static _test(data, key, config={}){ var b = Buffer.from(data.replaceAll(" ",""),"hex") const d = new this(null,config) d.initMetadata() Object.keys(d.getPaths()).forEach((tag)=>{ d.on(tag,(v)=>console.log(`${tag}=${v}`)) }) if (key) { d.encryptionKey = key b = d.decrypt(b) console.log(b) } d.emitValuesFrom(b) d.removeAllListeners() } //static utility Functions /** * * @param {*} v1 * @param {*} v2 * @returns NaN if v1 equals v2 otherwise, v1 */ static NaNif(v1,v2) { return (v1==v2)?NaN:v1 } /** * * Following three functions are direct to DBUS functions to get * around node-ble's not always reliable and not always persistent * property get methods * * Subclasses do not need to call these functions * except in static ::identify(device) functions * * ::_getPropsProxy(device) need never be called by any subclass * * Instances do not to call these functions at all. * Instance device property gets are handled by the BTSensor class implementation * * @todo duplicate and derive a better {@link module:node-ble} * */ static async _getPropsProxy(device){ if (!device._propsProxy) { const objectProxy = await device.helper.dbus.getProxyObject(device.helper.service, device.helper.object) device._propsProxy = await objectProxy.getInterface('org.freedesktop.DBus.Properties') } return device._propsProxy } static async getDeviceProps(device, propNames=[]){ const _propsProxy = await this._getPropsProxy(device) const rawProps = await _propsProxy.GetAll(device.helper.iface) const props = {} for (const propKey in rawProps) { if (propNames.length==0 || propNames.indexOf(propKey)>=0) props[propKey] = rawProps[propKey].value } return props } static async getDeviceProp(device, prop){ const _propsProxy = await this._getPropsProxy(device) try{ const rawProps = await _propsProxy.Get(device.helper.iface,prop) return rawProps?.value } catch(e){ return null //Property $prop (probably) doesn't exist in $device } } //End static utitity functions //static identity functions static identify(device){ throw new Error("BTSensor is an abstract class. ::identify must be implemented by the subclass") } static DisplayName() { return `${this.name} (${this.Domain.name}) `} /** * getManufacturerID is used to help ID the manufacturer of a device * * NOTE: Not all devices advertise their ManufacturerID in their ManufacturerData key * * @param {Device} device * @returns the numeric ID of a device's manufacturer iff the device * advertises ManufacturerData otherwise, null * */ static async getManufacturerID(device){ const md = await this.getDeviceProp(device,'ManufacturerData') if (!md) return null const keys = Object.keys(md) if (keys && keys.length>0){ return parseInt(keys[keys.length-1]) } return null } //END static identity functions _debugLog=[] _errorLog=[] _error = false getErrorLog(){ return this._errorLog } getDebugLog(){ return this._debugLog } debug(message){ if (this._app) this._app.debug(`(${this.getName()} ${this.getMacAddress()}) ${message}`) else console.log(`(${this.getName()} ${this.getMacAddress()})) ${message}`) this._debugLog.push({timestamp:Date.now(), message:message}) } unsetError(){ this._error=false this.emit("errorDetected", false) } setError(message){ if (this._app){ this._app.debug(`(${this.getName()} ${this.getMacAddress()}) ${message}`) this._app.setPluginError(`(${this.getName()} ${this.getMacAddress()}) ${message}`) } else console.log(`(${this.getName()} ${this.getMacAddress()}) ${message}`) this._errorLog.push({timestamp:Date.now(), message:message}) this._error=true this.emit("errorDetected", true) } //Instance Initialization functions /** * * init() initializes the sensor adding metadata as appropriate * as well as initializing any other instance-specific values * subclasses must call await super.init() * */ async initSchema(){ this._schema = { type: "object", title: this.getName(), properties:{ active: {title: "Active", type: "boolean", default: true }, discoveryTimeout: {title: "Device discovery timeout (in seconds)", type: "integer", default:30, minimum: 10, maximum: 600 }, params:{ title:`Device parameters`, type:"object", properties:{ noContactThreshold: {title: "If no contact (in seconds), raise warning. Set to 0 to disable", type: "integer", minimum: 0, maximum: 600, default: 2*(this?.discoveryTimeout??30) }, } }, paths:{ title:"Signalk Paths", description: `Signalk paths to be updated when the device's values change`, type:"object", properties:{} } } } if (this.hasGATT()){ this._schema.properties.gattParams={ title:`GATT Specific device parameters`, description: this.getGATTDescription(), type:"object", properties:{ useGATT: {title: "Use GATT connection", type: "boolean", default: true }, pollFreq: { type: "number", title: "Polling frequency in seconds"} } } } //create the 'name' parameter this.addDefaultParam("name") .default=this?.currentProperties?.Name //create the 'location' parameter //this.addDefaultParam("location") //create the 'RSSI' parameter this.addDefaultPath("RSSI","sensors.RSSI") this.getPath("RSSI").read=()=>{return this.getRSSI()} this.getPath("RSSI").read.bind(this) } async init(){ this.currentProperties = await this.constructor.getDeviceProps(this.device) this.setState("INITIALIZING"); await this.initSchema() this.initListen() if ( this.device instanceof OutOfRangeDevice) this.setState("OUT OF RANGE") else this.setState("DORMANT") } initListen(){ this.listen() } async activate(config, plugin){ this.setState("ACTIVATING") this.unsetError() if (config.paths){ this.createPaths(config,plugin.id) this.initPaths(config,plugin.id) this.debug(`Paths activated for ${this.getDisplayName()}`); } if (this.usingGATT()){ try { await this.activateGATT() } catch (e) { this.setError(`GATT services unavailable.`) throw new Error(`GATT services unavailable for ${this.getName()}. Reason: ${e}`) } } else { this.setState("ACTIVE") } this._active = true this._error = false this._propertiesChanged(this.currentProperties) } async getGATTServer(){ if (!this._gattServer) this._gattServer = await this.device.gatt() return this._gattServer } async activateGATT(isReconnecting=false){ this.setState("ACTIVATING GATT") await this.initGATTConnection(isReconnecting) if (this.pollFreq) await this.initGATTInterval() else await this.initGATTNotifications() } async stopAllGATTNotifications(){ if (this._gattServer) { //getGATTServer() was called which calls node-ble's Device::gatt() which needs cleaning up after (await this._gattServer.services()).forEach(async (uuid) => { await this._gattServer.getPrimaryService(uuid).then(async (service) => { (await service.characteristics()).forEach(async (uuid) => { (await service.getCharacteristic(uuid)).helper.removeListeners(); }); service.helper.removeListeners(); }); this._gattServer.helper.removeListeners(); }); } } async deactivateGATT(){ if (this.device) { this.debug(`Disconnecting from GATT server...`) await this.deviceDisconnect() } } /** * Add a metadatum instance to the sensor instance * * @param {String} tag * @param {...any} args * @returns {this.Metadatum} the new metadatum instance */ addMetadatum(tag, ...args){ const md = {} if (args[0]) md.unit = args[0] if (args[1]) md.title = args[1] if (args[2]) md.read = args[2] if (args[3]) md.gatt = args[3] if (args[4]) md.type = args[4] return this.addPath(tag,md) } addParameter(tag, param){ if (!param.type) param.type="string" if (param.isRequired) { if (!Object.hasOwn(this._schema.properties.params, "required")) this._schema.properties.params.required=[tag] else this._schema.properties.params.required.push(tag) } this._schema.properties.params.properties[tag]=param return this._schema.properties.params.properties[tag] } addPath(tag, path){ if (!path.type) path.type="string" if (path.isRequired) { if (!Object.hasOwn(this._schema.properties.paths, "required")) this._schema.properties.paths.required=[tag] else this._schema.properties.paths.required.push(tag) } if (!path.pattern) path.pattern=//"^(?:[^{}\\s]*\\{[a-zA-Z0-9]+\\}[^{}\\s]*|[^{}\\s]*)$" "^((\\{[a-zA-Z0-9]+\\}|[a-zA-Z0-9]+))(\\.(\\{[a-zA-Z0-9]+\\}|[a-zA-Z0-9]+))*$" this._schema.properties.paths.properties[tag]=path return this._schema.properties.paths.properties[tag] } addGATTParameter(tag, param){ if (!param.type) param.type="string" if (param.isRequired) { if (!Object.hasOwn(this._schema.properties.gattParams, "required")) this._schema.properties.gattParams.required=[tag] else this._schema.properties.gattParams.required.push(tag) } return this._schema.properties.gattParams.properties[tag]=param } addDefaultPath(tag,defaultPath){ const path = eval(`BTSensor.DEFAULTS.${defaultPath}`) return this.addPath(tag,Object.assign({}, path)) } addDefaultParam(tag,required=false){ const param = Object.assign({}, BTSensor.DEFAULTS.params[tag]) param.isRequired=required return this.addParameter(tag,param) } getJSONSchema(){ return this._schema } //GATT Initialization functions /** * Subclasses providing GATT support should override this method * * initGATTFunction is where subclasses are expected to connect to their devices and * make any GATTServer and GATTCharacteristic connections necessary * * see: VictronBatteryMonitor for an example */ async initGATTConnection(isReconnecting=false, retries=5, retryInterval=3){ await this.connectDevice(isReconnecting,retries, retryInterval); } /** * Subclasses providing GATT support should override this method * * initGATTNotifications() is where subclasses are expected setup listeners to their * GATTCharacteristics and emit values when received * * see: VictronBatteryMonitor for an example */ async initGATTNotifications(){ throw new Error("::initGATTNotifications() should be implemented by the BTSensor subclass") } isConnected(){ return this?._connected??false } async connectDevice( isReconnecting, retries=5, retryInterval=3){ for (let attempts = 0; attempts<retries; attempts++) { try { this.debug( `Trying to connect (attempt # ${attempts +1}) to Bluetooth device.` ); await this.deviceConnect(isReconnecting); return this.device } catch (e){ if (attempts==retries) throw new Error (`(${this.getName}) Unable to connect to Bluetooth device after ${attempts} attempts`) this.debug(`Error connecting to device. Retrying... `); await new Promise((r) => setTimeout(r, retryInterval*1000)); } } } setConnected(state){ this._connected=state this.setState(state?"CONNECTED":"DISCONNECTED") } isError(){ return this._error } deviceConnect(isReconnecting=false, autoReconnect=false) { return connectQueue.enqueue( async ()=>{ this.debug(`Connecting... ${this.getName()}`) this.setState("CONNECTING") if (!isReconnecting){ this.device.on("disconnect", () => { this.debug("Device disconnected.") this.setConnected(false) }) this.device.on("connect", () => { this.debug("Device connected.") this.setConnected(true) }) } await this.device.helper.callMethod('Connect') this.setConnected(true) this.debug(`Connected to ${this.getName()}`) if (!isReconnecting) { this.device.helper.on( "PropertiesChanged", (propertiesChanged) => { if ("Connected" in propertiesChanged) { const { value } = propertiesChanged.Connected; if (value) { this.device.emit("connect", { connected: true }); } else { this.device.emit("disconnect", { connected: false }); } } } ); if (autoReconnect){ this.device.on("disconnect", async () => { if (this.isActive()) { this.debug( `Device disconnected. Attempting to reconnect to ${this.getName()}` ); try { await this.deactivateGATT(); await this.activateGATT(true); this.debug(`(${this.getName()}) Device reconnected.`); } catch (e) { this.setError( `Error while reconnecting: ${ e.message }` ); this.debug( `Error while reconnecting: ${ e.message }` ); this._app.debug(e); } } }); } } try { /* CAUTION: HACK AHEAD Bluez for some cockamamie reason (It's 2025 for chrissake. BLUETOOTH ISN'T JUST FOR DESKTOPS ANYMORE, BLUEZ DEVS!) SUSPENDS scanning while connected to a device. The next line of code gives the scanner a kick in the arse, starting it up again so, I dunno, another device might be able to connect and sensor classes could maybe get beacon updates. You know, the little things. */ await this._adapter.helper.callMethod('StopDiscovery') //await this._adapter.helper.callMethod('SetDiscoveryFilter', { // Transport: new Variant('s', this._adapter?._transport??"le") //}) await this._adapter.helper.callMethod('StartDiscovery') } catch (e){ //probably ignorable error. probably. console.log(e) } /* END HACK*/ }) } async deviceDisconnect(){ await this.stopAllGATTNotifications() await this.device.disconnect() this.setConnected(false) } async stopGATTNotifications(gattCharacteristic) { if (gattCharacteristic){ gattCharacteristic.removeAllListeners() try{ if (await gattCharacteristic.isNotifying()) await gattCharacteristic.stopNotifications(); } catch (e){ this.debug(`Error stopping notifications: ${e.message}`) } } } /** * The function calls #emitGATT() at the specified interval then calls deactivateGATT() * which disconnects from the device. */ async initGATTInterval(){ async function _emitGATT() { try { await this.initGATTConnection(true); await this.emitGATT(); } catch (error) { this.debug(error); this.setError(`Unable to emit values for device: ${error.message}`); } finally { await this.deactivateGATT().catch((e) => { this.debug(`Error deactivating GATT Connection: ${e.message}`); }); this.setState("WAITING"); } } _emitGATT = _emitGATT.bind(this); _emitGATT() this.intervalID = setInterval( async () => { await _emitGATT() } , this.pollFreq*1000) } /** * ::propertiesChanged() is a callback function invoked when the device's properties change * * NOTE: The function mucks about with node-ble internal functions to help make sure the * DBUS connection stays alive, doesn't tax resources and doesn't spit out spurious errors. */ initPropertiesChanged(){ this._propertiesChanged.bind(this) this.device.helper._prepare() this.device.helper.on("PropertiesChanged", ((props)=> { try{ this._propertiesChanged(props) } catch(error){ this.debug(`Error occured on ${this.getNameAndAddress()}: ${error?.message??error}`) this.debug(error) } })) } //END instance initialization functions //Metadata functions getPath(tag){ return this._schema.properties.paths.properties[tag] } getPaths(){ return this._schema.properties.paths.properties } getParams(){ return this._schema.properties.params.properties } getParameter(param){ return this.getParams()[param] } getGATTParams(){ return this._schema.properties.gattParams.properties } //End metadata functions //Sensor description functions getMacAddress(){ return this.currentProperties?.Address??"Unknown MAC" } static ImageFile = "bluetooth-logo.png" static Description = "Bluetooth device" static Manufacturer = "Unknown" static ImageSrc = "../bt-sensors-plugin-sk/images/" getImageFile(){ return this.constructor.ImageFile } getImageSrc(){ return this.constructor.ImageSrc } static getImageHTML() { return `<img src="${this.ImageSrc}${this.ImageFile}" style="float: left; margin-right: 10px;" height="150" object-fit="cover" ></img>` } getImageHTML(){ return `<img src="${this.getImageSrc()}${this.getImageFile()}" style="float: left; margin-right: 10px;" height="150" object-fit="cover" ></img>` } getTextDescription(){ return `${this.getName()} from ${this.getManufacturer()}` } static getTextDescription(){ return `${this.name} from ${this.Manufacturer}` } getDescription(){ return `<div>${this.getImageHTML()} ${this.getTextDescription()} </div>` } static getDescription(){ return `<div>${this.getImageHTML()} ${this.getTextDescription()} </div>` } getName(){ const name = this?.name??this.currentProperties.Name return name?name:"Unknown" } macAndName(){ if (this.getMacAddress()==null) debugger return `${this.getName().replaceAll(':', '-').replaceAll(" ","_")}-${this.getMacAddress().replaceAll(':', '-')}` } getNameAndAddress(){ return `${this.getName()} at ${this.getMacAddress()}` } getDisplayName(){ return `${ this.getName()} (${ this.getMacAddress()} RSSI: ${this.getRSSI()} db / ${this.getSignalStrength().toFixed()}%) ${ this.getBars()}` } //END sensor description functions //Device property functions getServiceData(key){ if (this.currentProperties.ServiceData) return this.valueIfVariant (this.currentProperties.ServiceData[key]) else return null } getManufacturerID(){ const md = this.currentProperties.ManufacturerData if (md){ const keys = Object.keys(this.valueIfVariant(md)) if (keys.length>0) return parseInt(keys[0]) } return null } getManufacturer(){ const id = this.getManufacturerID() return (id==null)?"Unknown manufacturer":BTCompanyMap.get(parseInt(id)) } getManufacturerData(key=null){ if (this.currentProperties.ManufacturerData) if (key) return this.valueIfVariant (this.currentProperties.ManufacturerData[key]) else return(this.valueIfVariant (this.currentProperties.ManufacturerData)) else return null } //END Device property functions //Sensor RSSI state functions getRSSI(){ return this.currentProperties?.RSSI??NaN } isPaired(){ return this.currentProperties?.Paired??false } getSignalStrength(){ const rssi = this.getRSSI() if (!rssi) return NaN return signalQualityPercentQuad(rssi) } getState(){ return this._state } setState(state){ this._state = state this.emit("state",this._state) } getDomain(){ return this.constructor.Domain } isActive(){ return this?._active??false } getBars(){ const ss = this.getSignalStrength() var bars = "" if (ss>0) bars+= '\u{2582} ' //;"▂ " if (ss>=30) bars+= "\u{2584} " if (ss>=60) bars+= "\u{2586} " if (ss > 80) bars+= "\u{2588}" return bars } //End Sensor RSSI state functions //Sensor GATT state functions hasGATT(){ return false } usingGATT(){ return this.useGATT } getGATTDescription() { return "" } //END Sensor GATT state functions //Sensor listen-to-changes functions /** * callback function on a device's properties changing * BTSensor subclasses should override this function but call super.propertiesChanged(props) first * This function stores the latest broadcast RSSI ServiceData and ManufacturerData in the * currentproperties instance variable eliminating the need to make async calls to DBUS for same. * * @param {*} props which contains ManufacturerData and ServiceData (where the sensor's data resides) * set up by BTSensor::initPropertiesChanged() */ _propertiesChanged(props){ this._lastContact=Date.now() if (props.RSSI) { this.currentProperties.RSSI=this.valueIfVariant(props.RSSI) this.emit("RSSI", this.currentProperties.RSSI) //tell any RSSI listeners of the new value } if (props.ServiceData) this.currentProperties.ServiceData=this.valueIfVariant(props.ServiceData) if (props.ManufacturerData) this.currentProperties.ManufacturerData=this.valueIfVariant(props.ManufacturerData) if (this.isActive()) this.propertiesChanged(props) } propertiesChanged(props){ //implemented by subclass } /** * */ async emitGATT(){ throw new Error("Subclass must implement ::emitGATT function") } getCurrentValue(tag){ return this._currentValues[tag] } setCurrentValue(tag,value){ this._currentValues[tag]=value } emit(tag, value){ super.emit(tag, value) if (this.usingGATT()) //update last contact time only for GATT devices //which do not receive propertyChanged events when connected this._lastContact=Date.now() this.setCurrentValue(tag,value) } emitData(tag, buffer, ...args){ const md = this.getPath(tag) if (md && md.read) this.emit(tag, md.read(buffer, ...args)) } emitValuesFrom(buffer){ Object.keys(this.getPaths()) .forEach( (tag)=>this.emitData(tag,buffer) ) } /** * ::reactivate() */ async reactivate(){ await this.stopListening() this.listen() this.currentProperties = await this.constructor.getDeviceProps(this.device) if (this.usingGATT()){ try { await this.activateGATT(true) } catch (e) { this.setError(`GATT services unavailable for ${this.getName()}. Reason: ${e}`) return } } this.setState("ACTIVE") this._propertiesChanged(this.currentProperties) } /** * Listen to sensor. * ::listen() sets up listeners for property changes thru ::propertiesChanged(props) */ listen(){ try{ this.initPropertiesChanged() } catch(e){ this.debug(e) } return this } /** * Stop Listening to sensor. * Implemented by subclass if additional behavior necessary (like disconnect() from device's GattServer etc.) * * Called automatically by Plugin::plugin.stop() */ async stopListening(){ this.removeAllListeners() this.device.removeAllListeners() this.device.helper.removeListeners() if (this.intervalID){ clearInterval(this.intervalID) } if( this.usingGATT() ) await this.deactivateGATT() this.setState("DORMANT") } //END Sensor listen-to-changes functions //Instance utility functions NaNif(v1,v2) { return this.constructor.NaNif(v1,v2) } valueIfVariant(obj){ if (obj?.constructor && obj.constructor.name=='Variant') return obj.value else return obj } //End instance utility functions createPaths(config, id){ // const source = `${this.getName()} (${id})` Object.keys(this.getPaths()).forEach((tag)=>{ const pathMeta=this.getPath(tag) const path = config.paths[tag] if (!(path===undefined)) { const preparedPath = this._app.handleMessage(id, { updates: [{ meta: [{path: this.preparePath(path), value: { units: pathMeta?.unit, zones:pathMeta?.zones, renderer:pathMeta?.renderer } }]}] }) } }) } initPaths(deviceConfig, id){ const source = this.getName() Object.keys(this.getPaths()).forEach((tag)=>{ const pathMeta=this.getPath(tag) const path = deviceConfig.paths[tag]; if (!(path === undefined)) { let preparedPath = this.preparePath(path) this.on(tag, (val)=>{ this.updatePath(preparedPath,val, id, source) }) } }) } updatePath(path, val, id, source){ this._app.handleMessage(id, {updates: [ { $source: source, values: [ { path: path, value: val }] } ] }) } elapsedTimeSinceLastContact(){ if (this.device instanceof OutOfRangeDevice) return Infinity else return (Date.now()-this?._lastContact??Date.now())/1000 } prepareConfig(config){ if (!config.params.sensorClass) config.params.sensorClass=this.constructor.name } preparePath(str) { const regex = /\{([^}]+)\}/g; let match; let resultString = ""; let lastIndex = 0; while ((match = regex.exec(str)) !== null) { const fullMatch = match[0]; const keyToAccess = match[1].trim(); // Append the text before the current curly braces resultString += str.substring(lastIndex, match.index); lastIndex = regex.lastIndex; try { let evalResult = this[keyToAccess]; if (typeof evalResult === 'function'){ evalResult= evalResult.call(this) } resultString += evalResult !== undefined ? evalResult.replace(/\s+/g,'_') : `${keyToAccess}_value_undefined`; } catch (error) { console.error(`Error accessing key '${keyToAccess}':`, error); resultString += fullMatch; // Keep the original curly braces on error } } // Append any remaining text after the last curly braces resultString += str.substring(lastIndex); return resultString || str; // Return original string if no replacements were made } notifyUnableToCommunicate(){ this._app.handleMessage('bt-sensors-plugin-sk', { updates: [{ $source: this.getName(), timestamp: (new Date()).toISOString(), values: [{ path: `notifications.sensors.${this.macAndName()}`, value: { state: "warn", message: "Unable to communicate with sensor", method: ["visual", "sound"] } }] }] } ); } clearUnableToCommunicate(){ this._app.handleMessage('bt-sensors-plugin-sk', { updates: [{ $source: this.getName(), timestamp: (new Date()).toISOString(), values: [{ path: `notifications.sensors.${this.macAndName()}`, value: null }] }] } ); } notifyNoContact(){ this._app.handleMessage('bt-sensors-plugin-sk', { updates: [{ $source: this.getName(), timestamp: (new Date()).toISOString(), values: [{ path: `notifications.sensors.${this.macAndName()}`, value: { state: "warn", message: `No contact with sensor for ${this.elapsedTimeSinceLastContact()} seconds`, method: ["visual", "sound"] } }] }] } ); } clearNoContact(){ this._app.handleMessage('bt-sensors-plugin-sk', { updates: [{ $source: this.getName(), timestamp: (new Date()).toISOString(), values: [{ path: `notifications.sensors.${this.macAndName()}`, value: { state: "normal", message: `Communication with sensor normal`, method: [] } }] }] } ); } getBatteryStrength(){ return this.getCurrentValue(this.constructor.batteryStrengthTag) } } module.exports = BTSensor