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
868 lines (764 loc) • 24.4 kB
JavaScript
const packageInfo = require("./package.json")
const {createBluetooth} = require('@naugehyde/node-ble')
const {Variant} = require('@jellybrick/dbus-next')
const {bluetooth, destroy} = createBluetooth()
const BTSensor = require('./BTSensor.js')
const BLACKLISTED = require('./sensor_classes/BlackListedDevice.js')
const OutOfRangeDevice = require("./OutOfRangeDevice.js")
const { createChannel, createSession } = require("better-sse");
const { clearTimeout } = require('timers')
const loadClassMap = require('./classLoader.js')
const { debug } = require("node:console")
class MissingSensor {
constructor(config){
this.config=config
this.addPath=BTSensor.prototype.addPath.bind(this)
this.addParameter=BTSensor.prototype.addParameter.bind(this)
this.addDefaultPath=BTSensor.prototype.addDefaultPath.bind(this)
this.addDefaultParam=BTSensor.prototype.addDefaultParam.bind(this)
this.getPath=BTSensor.prototype.getPath.bind(this)
this.getImageSrc=BTSensor.prototype.getImageSrc.bind(this)
this.getJSONSchema = BTSensor.prototype.getJSONSchema.bind(this)
this.initSchema = BTSensor.prototype.initSchema.bind(this)
this.initSchema()
var keys = Object.keys(config?.paths??{})
keys.forEach((key)=>{
this.addPath(key,
{ type: config.paths[key]?.type??'string',
title: config.paths[key].title} )
} )
keys = Object.keys(config?.params??{})
keys.forEach((key)=>{
this.addParameter(key,
{ type: config.params[key]?.type??'string',
title: config.params[key].title
})
this[key]=config.params[key]
})
this.mac_address = config.mac_address
}
hasGATT(){
return this.config.gattParams
}
initGATTConnection(){
}
getGATTDescription(){
return ""
}
getMetadata(){
return this.metadata
}
getMacAddress(){
return this.mac_address
}
getDomain(){
return BTSensor.SensorDomains.unknown
}
getDescription(){
return ""
}
getName(){
return `${this?.name??"Unknown device"} (OUT OF RANGE)`
}
getDisplayName(){
return `(${this.getName()} ${this.getMacAddress()})`
}
getRSSI(){
return NaN
}
getBatteryStrength(){
return NaN
}
getState(){
return "OUT_OF_RANGE"
}
stopListening(){}
listen(){}
isActive(){
return false
}
isConnected(){return false}
elapsedTimeSinceLastContact(){
return NaN
}
getSignalStrength(){
return NaN
}
prepareConfig(){
}
getErrorLog(){
return []
}
getDebugLog(){
return []
}
removeAllListeners(){
}
on(){
}
stopNotifications(){}
isError(){
return true
}
}
module.exports = function (app) {
var deviceConfigs=[]
var starts=0
var plugin = {
debug (message) {
app.debug(message)
const logEntry = {
timestamp: Date.now(),
message: message
}
this.log.push(logEntry)
if (channel)
channel.broadcast(logEntry,"pluginDebug")
},
setError( error ){
app.setPluginError(error)
app.debug(error)
const logEntry = {
timestamp: Date.now(),
message: error
}
this.errorLog.push(logEntry)
if (channel)
channel.broadcast( logEntry, "pluginError" )
},
setStatusText( status ){
app.setPluginStatus(status)
const logEntry = {timestamp: Date.now(), message: status}
this.log.push(logEntry)
if (channel)
channel.broadcast( logEntry, "pluginStatusText" )
},
setFatalError( error ){
this.setError(`FATAL ERROR: ${error}`)
this.stop()
}
}
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';
plugin.log = []
plugin.errorLog = []
plugin.schema = {
type: "object",
htmlDescription:
`<h2><a href="https://github.com/naugehyde/bt-sensors-plugin-sk/tree/1.2.0-beta#configuration" target="_blank">Plugin Documenation</a><p/><a href="https://github.com/naugehyde/bt-sensors-plugin-sk/issues/new/choose" target="_blank">Report an issue</a><p/><a href="https://discord.com/channels/1170433917761892493/1295425963466952725" target="_blank">Discord thread</a></h2>
`,
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"]},
duplicateData: {title: "Set scanner to report duplicate data", type: "boolean", default: false, },
discoveryTimeout: {title: "Default device discovery timeout (in seconds)",
type: "integer", default: 30,
minimum: 10,
maximum: 3600
},
inactivityTimeout: {title: "Inactivity timeout in seconds -- set to 0 to disable. (If no contact with any sensors for this period, the plugin will attempt to power cycle the Bluetooth adapter.)",
type: "integer", default: 0,
minimum: 0,
maximum: 3600
},
discoveryInterval: {title: "Scan for new devices interval (in seconds-- 0 for no new device scanning)",
type: "integer",
default: 10,
minimum: 0
},
}
}
plugin.started=false
var discoveryIntervalID, progressID, progressTimeoutID, deviceHealthID
var adapter
const channel = createChannel()
plugin.debug(`Loading plugin ${packageInfo.version}`)
const sensorMap=new Map()
plugin.start = async function (options, restartPlugin) {
const classMap = loadClassMap(app)
plugin.started=true
var adapterID=options.adapter
var foundConfiguredDevices=0
if (Object.keys(options).length==0){ //empty config means initial startup. save defaults and enabled=true.
let json = {configuration:{adapter:"hci0", transport:"le", discoveryTimeout:30, discoveryInterval:10}, enabled:true, enableDebug:false}
let appDataDirPath = app.getDataDirPath()
let jsonFile = appDataDirPath+'.json'
const fs = require("node:fs")
try {
fs.writeFileSync(jsonFile, JSON.stringify(json, null,2))
options=json
} catch(err){
console.log(`Error writing initial config: ${err.message} `)
console.log(err)
}
}
plugin.registerWithRouter = function(router) {
router.get('/getSensorInfo', async (req, res) => {
const _sensor = sensorMap.get(req.query?.mac_address)
const _class = classMap.get(req.query?.class)
let _tempSensor = null
if (_sensor &&_class && _sensor instanceof classMap.get("UNKNOWN")){
try {
_tempSensor = new _class ( _sensor.device )
_tempSensor.currentProperties=_sensor.currentProperties
_tempSensor._app = app
_tempSensor._adapter=adapter
await _tempSensor.init()
const _json = sensorToJSON(_tempSensor)
res.status(200).json(_json)
} catch (e){
res.status(400).send(`Invalid request: ${e.message}`)
}
finally{
if (_tempSensor)
_tempSensor.stopListening()
}
} else{
res.status(404).json({message: `Invalid request`})
}
})
router.post('/updateSensorData', async (req, res) => {
const sensor = sensorMap.get(req.body.mac_address)
sensor.prepareConfig(req.body)
const i = deviceConfigs.findIndex((p)=>p.mac_address==req.body.mac_address)
if (i<0){
if (!options.peripherals){
if (!options.hasOwnProperty("peripherals"))
options.peripherals=[]
options.peripherals=[]
}
options.peripherals.push(req.body)
} else {
options.peripherals[i] = req.body
}
deviceConfigs=options.peripherals
app.savePluginOptions(
options, async () => {
res.status(200).json({message: "Sensor updated"})
if (sensor) {
if (sensor.isActive())
await sensor.stopListening()
removeSensorFromList(sensor)
}
initConfiguredDevice(req.body)
}
)
});
router.post('/removeSensorData', async (req, res) => {
const sensor = sensorMap.get(req.body.mac_address)
if (!sensor) {
res.status(404).json({message: "Sensor not found"})
return
}
const i = deviceConfigs.findIndex((p)=>p.mac_address==req.body.mac_address)
if (i>=0){
deviceConfigs.splice(i,1)
}
if (sensor.isActive())
await sensor.stopListening()
if (sensorMap.has(req.body.mac_address))
sensorMap.delete(req.body.mac_address)
app.savePluginOptions(
options, () => {
res.status(200).json({message: "Sensor updated"})
channel.broadcast({},"resetSensors")
}
)
});
router.post('/updateBaseData', async (req, res) => {
Object.assign(options,req.body)
app.savePluginOptions(
options, () => {
res.status(200).json({message: "Plugin updated"})
channel.broadcast({},"pluginRestarted")
restartPlugin(options)
}
)
});
router.get('/getBaseData', (req, res) => {
res.status(200).json(
{
schema: plugin.schema,
data: {
adapter: options.adapter,
transport: options.transport,
duplicateData: options.duplicateData,
discoveryTimeout: options.discoveryTimeout,
discoveryInterval: options.discoveryInterval,
inactivityTimeout: options.inactivityTimeout
}
}
);
})
router.get('/getSensors', (req, res) => {
const t = sensorsToJSON()
res.status(200).json(t)
});
router.get('/getProgress', (req, res) => {
let deviceCount = deviceConfigs.filter((dc)=>dc.active).length
const json = {"progress":foundConfiguredDevices/deviceCount, "maxTimeout": 1,
"deviceCount":foundConfiguredDevices,
"totalDevices": deviceCount}
res.status(200).json(json)
});
router.get('/getPluginState', async (req, res) => {
res.status(200).json({
"connectionId": Date.now(),
"state":(plugin.started?"started":"stopped")
})
});
router.get("/sse", async (req, res) => {
const session = await createSession(req,res)
channel.register(session)
req.on("close", ()=>{
channel.deregister(session)
})
});
};
function sensorsToJSON(){
return Array.from(
Array.from(sensorMap.values()).filter((s)=>!(s instanceof BLACKLISTED) ).map(
sensorToJSON
))
}
function getSensorInfo(sensor){
const s = sensor.getState()
return { mac: sensor.getMacAddress(),
name: sensor.getName(),
class: sensor.constructor.name,
domain: sensor.getDomain().name,
state: sensor.getState(),
error: sensor.isError(),
errorLog: sensor.getErrorLog(),
debugLog: sensor.getDebugLog(),
RSSI: sensor.getRSSI(),
signalStrength: sensor.getSignalStrength(),
batteryStrength: sensor.getBatteryStrength(),
connected: sensor.isConnected(),
lastContactDelta: sensor.elapsedTimeSinceLastContact()
}
}
function sensorToJSON(sensor){
const config = getDeviceConfig(sensor.getMacAddress())
const schema = sensor.getJSONSchema()
schema.htmlDescription = sensor.getDescription()
return {
info: getSensorInfo(sensor),
schema: schema,
config: config?config:{},
configCopy: JSON.parse(JSON.stringify(config?config:{}))
}
}
async function startScanner(options) {
const transport = options?.transport??"le"
const duplicateData = options?.duplicateData??false
plugin.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) {
plugin.debug(`Setting Bluetooth transport option to ${transport}. DuplicateData to ${duplicateData}`)
await adapter.helper.callMethod('SetDiscoveryFilter', {
Transport: new Variant('s', transport),
DuplicateData: new Variant('b', duplicateData)
})
}
await adapter.helper.callMethod('StartDiscovery')
}
catch (error){
plugin.debug(error)
}
}
function updateSensor(sensor){
channel.broadcast(getSensorInfo(sensor), "sensorchanged")
}
function removeSensorFromList(sensor){
sensor.removeAllListeners("state")
sensor.removeAllListeners("connected")
sensor.removeAllListeners("error")
sensor.removeAllListeners("debug")
sensor.removeAllListeners("RSSI")
sensorMap.delete(sensor.getMacAddress())
channel.broadcast({mac:sensor.getMacAddress()},"removesensor")
}
function addSensorToList(sensor){
sensorMap.set(sensor.getMacAddress(),sensor)
sensor.on("state", (state)=>{
updateSensor(sensor)
})
sensor.on("connected", (state)=>{
updateSensor(sensor)
})
sensor.on("errorDetected",(error)=>{
updateSensor(sensor)
})
sensor.on("debug", ()=>{
updateSensor(sensor)
})
sensor.on(sensor.constructor.batteryStrengthTag,()=>{
updateSensor(sensor)
})
sensor._lastRSSI=-1*Infinity
sensor.on("RSSI",(()=>{
if (Date.now()-sensor._lastRSSI > 10000) { //only update RSSI on client every 10 seconds
sensor._lastRSSI=Date.now()
updateSensor(sensor)
}
}))
channel.broadcast(sensorToJSON(sensor),"newsensor");
}
function deviceNameAndAddress(config){
return `${config?.name??""}${config.name?" at ":""}${config.mac_address}`
}
function createSensor(adapter, config) {
return new Promise( ( resolve, reject )=>{
var s
const startNumber=starts
adapter.waitDevice(config.mac_address,(config?.discoveryTimeout??30)*1000)
.then(async (device)=> {
if (startNumber != starts ) {
return
}
s = await instantiateSensor(device,config)
if (!s)
reject("Unable to create sensor")
else
if (s instanceof BLACKLISTED)
reject ( `Device is blacklisted (${s.reasonForBlacklisting()}).`)
else{
addSensorToList(s)
resolve(s)
}
})
.catch(async (e)=>{
if (s)
s.stopListening()
else{
const device = new OutOfRangeDevice(adapter, config)
s = await instantiateSensor(device,config)
device.once("deviceFound",async (device)=>{
s.device=device
s.listen()
if (config.active) {
s.clearUnableToCommunicate()
await s.activate(config, plugin)
}
else {
s.unsetError()
s.setState("DORMANT")
}
removeSensorFromList(s)
addSensorToList(s)
})
addSensorToList(s)
resolve(s)
}
if (startNumber == starts ) {
const errorTxt = `Unable to communicate with device ${deviceNameAndAddress(config)} Reason: ${e?.message??e}`
if (config.active) {
if(s) {
s.setError(errorTxt)
s.notifyUnableToCommunicate()
}
else
plugin.setError(errorTxt)
}
plugin.debug(errorTxt)
plugin.debug(e)
reject( e?.message??e )
}
})})
}
function getDeviceConfig(mac){
return deviceConfigs.find((p)=>p.mac_address==mac)
}
async function getClassFor(device,config){
if (config.params?.sensorClass){
const c = classMap.get(config.params.sensorClass)
if (c==null)
throw new Error ("Cannot find class "+config.params.sensorClass)
return c
}
for (var [clsName, cls] of classMap) {
if (clsName.startsWith("_")) continue
const c = await cls.identify(device)
if (c) {
if (Object.hasOwn(config, "params")) {
config.params.sensorClass=clsName
}
return c
}
}
return classMap.get('UNKNOWN')
}
async function instantiateSensor(device,config){
try{
const c = await getClassFor(device,config)
c.debug=app.debug
const sensor = new c(device, config?.params, config?.gattParams)
sensor._paths=config.paths //this might be a good candidate for refactoring
sensor._app=app
sensor._adapter=adapter //HACK!
await sensor.init()
return sensor
}
catch(error){
if (!config.unconfigured) {
const msg = `Unable to instantiate ${await BTSensor.getDeviceProp(device,"Address")}: ${error.message} `
plugin.debug(msg)
plugin.debug(error)
if (config.active)
plugin.setError(msg)
}
return null
}
}
function initConfiguredDevice(deviceConfig){
const startNumber=starts
plugin.setStatusText(`Initializing ${deviceNameAndAddress(deviceConfig)}`);
if (!deviceConfig.discoveryTimeout)
deviceConfig.discoveryTimeout = options.discoveryTimeout
createSensor(adapter, deviceConfig).then(async (sensor)=>{
if (startNumber != starts ) {
return
}
if (deviceConfig.active && !(sensor.device instanceof OutOfRangeDevice) ) {
plugin.setStatusText(`Listening to ${++foundConfiguredDevices} sensors.`);
try {
await sensor.activate(deviceConfig, plugin)
} catch (e){
sensor.setError(`Unable to activate sensor. Reason: ${e.message}`)
}
}
})
.catch((error)=>
{
if (deviceConfig?.unconfigured??false) return
if (startNumber != starts ) {
return
}
const msg =`Sensor at ${deviceConfig.mac_address} unavailable. Reason: ${error}`
plugin.debug(msg)
if (deviceConfig.active)
plugin.setError(msg)
const sensor=new MissingSensor(deviceConfig)
++foundConfiguredDevices
addSensorToList(sensor) //add sensor to list with known options
})
}
function findDevices (discoveryTimeout) {
const startNumber = starts
plugin.setStatusText("Scanning for new Bluetooth devices...");
adapter.devices().then( (macs)=>{
if (startNumber != starts ) {
return
}
for (const mac of macs) {
var deviceConfig = getDeviceConfig(mac)
const sensor = sensorMap.get(mac)
if (sensor) {
if (sensor instanceof MissingSensor){
removeSensorFromList(sensor)
initConfiguredDevice(deviceConfig)
}
} else {
if (!deviceConfig) {
deviceConfig = {mac_address: mac,
discoveryTimeout: discoveryTimeout,
active: false, unconfigured: true}
initConfiguredDevice(deviceConfig)
}
}
}
})
}
function findDeviceLoop(discoveryTimeout, discoveryInterval, immediate=true ){
if (immediate)
findDevices(discoveryTimeout)
discoveryIntervalID =
setInterval( findDevices, discoveryInterval*1000, discoveryTimeout)
}
channel.broadcast({state:"started"},"pluginstate")
if (!adapterID || adapterID=="")
adapterID = "hci0"
//Check if Adapter has changed since last start()
if (adapter) {
if (adapter.adapter!=adapterID) {
adapter.helper._propsProxy.removeAllListeners()
adapter=null
}
}
//Connect to adapter
if (!adapter){
plugin.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
plugin.setStatusText(`Bluetooth Adapter ${adapterID} turned off. Plugin disabled.`)
await plugin.stop()
}
} else {
await restartPlugin(options)
}
}
})
if (!await adapter.isPowered()) {
plugin.debug(`Bluetooth Adapter ${adapterID} not powered on.`)
plugin.setError(`Bluetooth Adapter ${adapterID} not powered on.`)
await plugin.stop()
return
}
}
sensorMap.clear()
if (channel){
channel.broadcast({state:"started"},"pluginstate")
}
deviceConfigs=options?.peripherals??[]
if (plugin.stopped) {
plugin.stopped=false
}
try{
const activeAdapters = await bluetooth.activeAdapters()
if (activeAdapters.length==0){
plugin.setError("No active Bluetooth adapters found.")
}
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()})`)
}}
catch(e){
plugin.setError(`Unable to get adapters: ${e.message}`)
}
await startScanner(options)
if (starts>0){
plugin.debug(`Plugin ${packageInfo.version} restarting...`);
} else {
plugin.debug(`Plugin ${packageInfo.version} started` )
}
starts++
if (!await adapter.isDiscovering())
try{
await startScanner(options)
} catch (e){
plugin.setError(`Error starting scan: ${e.message}`)
}
if (!(deviceConfigs===undefined)){
const maxTimeout=Math.max(...deviceConfigs.map((dc)=>dc?.discoveryTimeout??options.discoveryTimeout))
const totalDevices = deviceConfigs.filter((dc)=>dc.active).length
var progress = 0
if (progressID==null)
progressID = setInterval(()=>{
channel.broadcast({"progress":++progress, "maxTimeout": maxTimeout, "deviceCount":foundConfiguredDevices, "totalDevices": totalDevices},"progress")
if ( foundConfiguredDevices==totalDevices){
progressID,progressTimeoutID = null
clearTimeout(progressTimeoutID)
clearInterval(progressID)
progressID = null
}
},1000);
if (progressTimeoutID==null)
progressTimeoutID = setTimeout(()=> {
if (progressID) {
clearInterval(progressID);
progressID=null
channel.broadcast({"progress":maxTimeout, "maxTimeout": maxTimeout, "deviceCount":foundConfiguredDevices, "totalDevices": totalDevices},"progress")
}
}, (maxTimeout+1)*1000);
for (const config of deviceConfigs) {
initConfiguredDevice(config)
}
}
const minTimeout=Math.min(...deviceConfigs.map((dc)=>dc?.discoveryTimeout??options.discoveryTimeout))
const intervalTimeout = ((minTimeout==Infinity)?(options?.discoveryTimeout??plugin.schema.properties.discoveryTimeout.default):minTimeout)*1000
deviceHealthID = setInterval( async ()=> {
let lastContactDelta=Infinity
sensorMap.forEach((sensor)=>{
const config = getDeviceConfig(sensor.getMacAddress())
const dt = config?.discoveryTimeout??options.discoveryTimeout
const lc=sensor.elapsedTimeSinceLastContact()
if (lc<lastContactDelta) //get min last contact delta
lastContactDelta=lc
if (lc > dt) {
updateSensor(sensor)
}
if (sensor.noContactThreshold && lc>sensor.noContactThreshold){
if (sensor.isActive())
sensor.notifyNoContact()
}
else{
if (sensor.isActive())
sensor.clearNoContact()
}
})
if (sensorMap.size && options.inactivityTimeout && lastContactDelta > options.inactivityTimeout)
{
plugin.debug(`No contact with any sensors for ${lastContactDelta} seconds. Recycling Bluetooth adapter.`)
await adapter.setPowered(false)
await adapter.setPowered(true)
}
}, intervalTimeout)
if (!options.hasOwnProperty("discoveryInterval" )) //no config -- first run
options.discoveryInterval = plugin.schema.properties.discoveryInterval.default
if (options.discoveryInterval && !discoveryIntervalID)
findDeviceLoop(options?.discoveryTimeout??plugin.schema.properties.discoveryTimeout.default,
options.discoveryInterval)
}
plugin.stop = async function () {
plugin.debug("Stopping plugin")
plugin.stopped=true
plugin.started=false
channel.broadcast({state:"stopped"},"pluginstate")
if (discoveryIntervalID) {
clearInterval(discoveryIntervalID)
discoveryIntervalID=null
}
if (progressID) {
clearInterval(progressID)
progressID=null
}
if (progressTimeoutID) {
clearTimeout(progressTimeoutID)
progressTimeoutID=null
}
if (deviceHealthID) {
clearTimeout(deviceHealthID)
deviceHealthID=null
}
if ((sensorMap)){
for await (const sensorEntry of sensorMap.entries()) {
try{
await sensorEntry[1].stopListening()
plugin.debug(`No longer listening to ${sensorEntry[0]}`)
}
catch (e){
plugin.setError(`Error stopping listening to ${sensorEntry[0]}: ${e.message}`)
}
}
}
sensorMap.clear()
if (adapter) {
adapter.helper._propsProxy.removeAllListeners()
if( await adapter.isDiscovering())
try{
await adapter.stopDiscovery()
plugin.debug('Scan stopped')
} catch (e){
plugin.setError(`Error stopping scan: ${e.message}`)
}
}
plugin.debug('BT Sensors plugin stopped')
}
return plugin;
}